PeachSyncService.kt

11.4 KB · raw

1package com.peachpasswords.sync23import android.app.Notification4import android.app.NotificationChannel5import android.app.NotificationManager6import android.app.PendingIntent7import android.app.Service8import android.content.BroadcastReceiver9import android.content.Context10import android.content.Intent11import android.os.Build12import android.os.IBinder13import androidx.core.app.NotificationCompat14import com.peachpasswords.MainActivity15import com.peachpasswords.R16import dagger.hilt.EntryPoint17import dagger.hilt.InstallIn18import dagger.hilt.android.AndroidEntryPoint19import dagger.hilt.android.EntryPointAccessors20import dagger.hilt.components.SingletonComponent21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import kotlinx.coroutines.CoroutineScope24import kotlinx.coroutines.SupervisorJob25import kotlinx.coroutines.cancel26import javax.inject.Inject2728/**29 * Keeps this device reachable on the relay so an already-paired peer can30 * reconnect. A browser extension can never listen, so if the phone is not31 * registered the two devices simply never sync and neither shows an error —32 * "no connection" and "nothing to sync" look identical.33 *34 * A browser extension can never listen for a connection, so the phone has to be35 * the side that is dialable — and a phone that is only dialable while its app36 * is foregrounded is not dialable in any useful sense. Doze and App Standby37 * will take an ordinary background socket away. This is the same standard38 * pattern Syncthing and Tailscale use for exactly the same reason: a foreground39 * service with a visible, honest notification.40 *41 * The notification is deliberately plain about what it is. A persistent42 * notification the user does not understand gets the app force-stopped, which43 * is the one outcome that breaks sync entirely.44 */45@AndroidEntryPoint46class PeachSyncService : Service() {4748    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener49    @Inject lateinit var vault: VaultRepository50    @Inject lateinit var deviceStore: PairedDeviceStore51    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore52    @Inject lateinit var syncAlertStore: SyncAlertStore53    @Inject lateinit var syncOutcomes: SyncOutcomeStore5455    private val scope = CoroutineScope(SupervisorJob())5657    override fun onBind(intent: Intent?): IBinder? = null5859    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {60        // The same restriction that can refuse [start] applies again here, and61        // a START_STICKY restart is exactly when it bites: the system brings us62        // back while the app is in the background. A service that stays up63        // without its notification is killed a second later by a different64        // exception, so stop deliberately and leave the reported reason65        // standing rather than come back to be refused every time.66        if (!startOrReportBlocked(syncOutcomes::report) {67                startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))68            }69        ) {70            stopSelf()71            return START_NOT_STICKY72        }73        val listenerStarted = startReconnectListener()74        // A start command means something changed — a new pairing, a manual75        // "sync now", the app coming forward. Re-read the device list now76        // rather than on the next interval; the listener is already running77        // on every start but the first.78        // Replace existing waiters as well as re-reading the device list. A79        // locally active coroutine does not prove its idle WebSocket still80        // routes through the relay, and "sync now" must recover that state.81        cloudRelayListener.requestReconcile(82            refreshExisting = shouldRefreshExistingWaiters(listenerStarted),83        )84        // START_STICKY: if the system reclaims us under pressure, come back —85        // a listener that silently stays down looks identical to a peer that86        // has simply gone offline, and the user gets no signal either way.87        return START_STICKY88    }8990    override fun onDestroy() {91        cloudRelayListener.stop()92        scope.cancel()93        super.onDestroy()94    }9596    /**97     * Only possible while unlocked — the sync identity is derived from the98     * vault's root key, so a locked phone genuinely has nothing to listen with.99     * That is also why this is re-attempted on every start command rather than100     * once: the service may well come up before the user has unlocked.101     *102     * The early return is deliberately the one terminating path that reports no103     * outcome. Coming up locked is the normal boot order, not a failed sync;104     * reporting it would leave a red light waiting on the other side of the105     * unlock screen. The locked case a person can actually see is reported from106     * `SyncViewModel.syncNow`, and from a session that reaches a locked vault107     * mid-handshake.108     */109    private fun startReconnectListener(): Boolean {110        val master = vault.syncMasterKey() ?: return false111        return try {112            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))113            val identity = buildLocalIdentity(this, master)114            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {115                if (!vault.isUnlocked.value) null else VaultSyncAdapter(116                    repo = vault,117                    ourDeviceId = getOrCreateDeviceId(this),118                    pairedDevices = deviceStore,119                    pendingVouched = pendingVouchedStore,120                    syncAlerts = syncAlertStore,121                )122            }123        } finally {124            VaultCrypto.zero(master)125        }126    }127128    companion object {129        fun start(context: Context) {130            val intent = Intent(context, PeachSyncService::class.java)131            startOrReportBlocked({ context.reportSyncOutcome(it) }) {132                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {133                    context.startForegroundService(intent)134                } else {135                    context.startService(intent)136                }137            }138        }139140        fun stop(context: Context) {141            context.stopService(Intent(context, PeachSyncService::class.java))142        }143    }144}145146internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted147148internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"149internal const val SYNC_NOTIFICATION_ID = 4201150internal const val ACTION_RESTORE_SYNC_NOTIFICATION =151    "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"152153internal fun buildSyncNotification(context: Context): Notification {154    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager155    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {156        manager.createNotificationChannel(157            NotificationChannel(158                SYNC_NOTIFICATION_CHANNEL_ID,159                "Peach sync",160                NotificationManager.IMPORTANCE_LOW,161            ).apply {162                description = "Shown while Peach is reachable for encrypted device sync."163                setShowBadge(false)164            },165        )166    }167    val open = PendingIntent.getActivity(168        context,169        0,170        Intent(context, MainActivity::class.java),171        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,172    )173    val restore = PendingIntent.getBroadcast(174        context,175        0,176        Intent(context, SyncNotificationDismissedReceiver::class.java)177            .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),178        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,179    )180    return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)181        .setContentTitle("Peach sync is active")182        .setContentText("Your other devices can reach this phone for encrypted sync.")183        .setSmallIcon(R.drawable.ic_launcher_monochrome)184        .setOngoing(true)185        .setPriority(NotificationCompat.PRIORITY_LOW)186        .setContentIntent(open)187        .setDeleteIntent(restore)188        .build()189        .also {190            // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows191            // users to swipe ongoing notifications individually, so the192            // deleteIntent above restores this status surface while the user193            // preference still says background sync is active.194            it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR195        }196}197198internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =199    action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled200201/**202 * Android 14 permits an ongoing foreground-service notification to be swiped203 * away while its service keeps running. Peach's notification is the only204 * visible indication that the phone remains reachable, so restore it after a205 * user dismissal whenever background sync is still enabled.206 */207class SyncNotificationDismissedReceiver : BroadcastReceiver() {208    override fun onReceive(context: Context, intent: Intent?) {209        if (!shouldRestoreSyncNotification(210                intent?.action,211                BackgroundSyncPreference.isEnabled(context),212            )213        ) {214            return215        }216        val manager =217            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager218        manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))219    }220}221222/**223 * Every sync path asks for this service first, so a throw here is a sync that224 * never happened — and it used to take the caller with it: a crash from the225 * pairing and unlock screens, nothing at all from the fire-and-forget226 * coroutines. Returns false when the start was refused, having said why.227 *228 * [IllegalStateException] rather than API 31's229 * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a230 * subclass of this one and does not exist on this app's minSdk, and 26..30231 * throws the plain one for the same background start. Catching the supertype232 * covers both without an SDK_INT guard around a class that may not be there.233 */234internal fun startOrReportBlocked(235    report: (SyncOutcomeSummary) -> Unit,236    start: () -> Unit,237): Boolean = try {238    start()239    true240} catch (blocked: IllegalStateException) {241    report(242        SyncOutcomeSummary(243            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,244            detail = blocked.message,245        ),246    )247    false248} catch (error: Exception) {249    // Nothing about asking for a service is worth crashing the app over; the250    // reason is the only part of this a person can act on.251    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))252    false253}254255/**256 * [PeachSyncService.start] is called from five screens and coordinators that257 * hold no injector of their own, so the store is fetched here rather than258 * threaded through every one of them. Wrapped because a store that cannot even259 * be opened must not become the crash the guard above exists to prevent.260 */261private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {262    runCatching {263        EntryPointAccessors264            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)265            .syncOutcomes()266            .report(summary)267    }268}269270@EntryPoint271@InstallIn(SingletonComponent::class)272internal interface SyncOutcomeEntryPoint {273    fun syncOutcomes(): SyncOutcomeStore274}