PeachSyncService.kt

12.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 kotlinx.coroutines.launch27import java.security.MessageDigest28import javax.inject.Inject2930/**31 * Keeps this device reachable on its deterministic relay waiter edges and32 * drives its dialer edges. Every supported client now uses the same public-key33 * role ordering, but Android still needs a foreground service: Doze and App34 * Standby will take an ordinary background socket away.35 *36 * The notification is deliberately plain about what it is. A persistent37 * notification the user does not understand gets the app force-stopped.38 */39@AndroidEntryPoint40class PeachSyncService : Service() {4142    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener43    @Inject lateinit var vault: VaultRepository44    @Inject lateinit var deviceStore: PairedDeviceStore45    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore46    @Inject lateinit var syncAlertStore: SyncAlertStore47    @Inject lateinit var syncOutcomes: SyncOutcomeStore48    @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore4950    private val scope = CoroutineScope(SupervisorJob())5152    override fun onBind(intent: Intent?): IBinder? = null5354    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {55        // The same restriction that can refuse [start] applies again here, and56        // a START_STICKY restart is exactly when it bites: the system brings us57        // back while the app is in the background. A service that stays up58        // without its notification is killed a second later by a different59        // exception, so stop deliberately and leave the reported reason60        // standing rather than come back to be refused every time.61        if (!startOrReportBlocked(syncOutcomes::report) {62                startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))63            }64        ) {65            stopSelf()66            return START_NOT_STICKY67        }68        if (vault.isUnlocked.value) {69            scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }70        }71        val listenerStarted = startReconnectListener()72        // A start command means something changed — a new pairing, a manual73        // "sync now", the app coming forward. Re-read the device list now74        // rather than on the next interval; the listener is already running75        // on every start but the first.76        // Replace existing waiters as well as re-reading the device list. A77        // locally active coroutine does not prove its idle WebSocket still78        // routes through the relay, and "sync now" must recover that state.79        cloudRelayListener.requestReconcile(80            refreshExisting = shouldRefreshExistingWaiters(listenerStarted),81        )82        // START_STICKY: if the system reclaims us under pressure, come back —83        // a listener that silently stays down looks identical to a peer that84        // has simply gone offline, and the user gets no signal either way.85        return START_STICKY86    }8788    override fun onDestroy() {89        cloudRelayListener.stop()90        scope.cancel()91        super.onDestroy()92    }9394    /**95     * Uses the Keystore-encrypted transport identity after full lock. The spool96     * has no vault root and can only move opaque entry envelopes.97     *98     * The early return is deliberately the one terminating path that reports no99     * outcome. Coming up locked is the normal boot order, not a failed sync;100     * reporting it would leave a red light waiting on the other side of the101     * unlock screen. The locked case a person can actually see is reported from102     * `SyncViewModel.syncNow`, and from a session that reaches a locked vault103     * mid-handshake.104     */105    private fun startReconnectListener(): Boolean {106        val master = vault.syncMasterKey() ?: return false107        var credentials: ReconnectListenerCredentials? = null108        return try {109            lockedSyncSpool.refresh(vault)110            credentials = deriveUnlockedListenerCredentials(111                masterKey = master,112                deviceId = getOrCreateDeviceId(this),113                deviceName = getDeviceName(this),114                relayUrl = RelaySettingsStore.readRelayUrl(this),115            )116            cloudRelayListener.start(117                scope,118                credentials.keys.secretKey,119                credentials.keys.publicKey,120                credentials.identity,121            ) {122                if (!vault.isUnlocked.value) lockedSyncSpool.adapter() else VaultSyncAdapter(123                    repo = vault,124                    ourDeviceId = getOrCreateDeviceId(this),125                    pairedDevices = deviceStore,126                    pendingVouched = pendingVouchedStore,127                    syncAlerts = syncAlertStore,128                )129            }130        } finally {131            VaultCrypto.zero(master)132            credentials?.keys?.secretKey?.let(VaultCrypto::zero)133        }134    }135136    companion object {137        fun start(context: Context) {138            val intent = Intent(context, PeachSyncService::class.java)139            startOrReportBlocked({ context.reportSyncOutcome(it) }) {140                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {141                    context.startForegroundService(intent)142                } else {143                    context.startService(intent)144                }145            }146        }147148        fun stop(context: Context) {149            context.stopService(Intent(context, PeachSyncService::class.java))150        }151    }152}153154internal data class ReconnectListenerCredentials(155    val keys: SyncIdentityKeypair,156    val identity: LocalDeviceIdentity,157)158159internal fun deriveUnlockedListenerCredentials(160    masterKey: ByteArray,161    deviceId: String,162    deviceName: String,163    relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,164): ReconnectListenerCredentials {165    val keys = deriveLocalSyncKeypair(masterKey, deviceId)166    val signing = deriveSyncSigningKeypair(masterKey, deviceId)167    val identity = LocalDeviceIdentity(168        deviceId = deviceId,169        deviceName = deviceName,170        deviceType = DeviceType.ANDROID,171        longTermPublicKeyHex = pubkeyToHex(keys.publicKey),172        signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),173        relayUrl = relayUrl,174    )175    if (!MessageDigest.isEqual(keys.publicKey, hexToPubkey(identity.longTermPublicKeyHex))) {176        VaultCrypto.zero(keys.secretKey)177        throw IllegalStateException("derived reconnect identity mismatch")178    }179    return ReconnectListenerCredentials(keys, identity)180}181182internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted183184internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"185internal const val SYNC_NOTIFICATION_ID = 4201186internal const val ACTION_RESTORE_SYNC_NOTIFICATION =187    "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"188189internal fun buildSyncNotification(context: Context): Notification {190    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager191    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {192        manager.createNotificationChannel(193            NotificationChannel(194                SYNC_NOTIFICATION_CHANNEL_ID,195                "Peach sync",196                NotificationManager.IMPORTANCE_LOW,197            ).apply {198                description = "Shown while Peach is reachable for encrypted device sync."199                setShowBadge(false)200            },201        )202    }203    val open = PendingIntent.getActivity(204        context,205        0,206        Intent(context, MainActivity::class.java),207        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,208    )209    val restore = PendingIntent.getBroadcast(210        context,211        0,212        Intent(context, SyncNotificationDismissedReceiver::class.java)213            .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),214        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,215    )216    return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)217        .setContentTitle("Peach sync is active")218        .setContentText("Your other devices can reach this phone for encrypted sync.")219        .setSmallIcon(R.drawable.ic_launcher_monochrome)220        .setOngoing(true)221        .setPriority(NotificationCompat.PRIORITY_LOW)222        .setContentIntent(open)223        .setDeleteIntent(restore)224        .build()225        .also {226            // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows227            // users to swipe ongoing notifications individually, so the228            // deleteIntent above restores this status surface while the user229            // preference still says background sync is active.230            it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR231        }232}233234internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =235    action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled236237/**238 * Android 14 permits an ongoing foreground-service notification to be swiped239 * away while its service keeps running. Peach's notification is the only240 * visible indication that the phone remains reachable, so restore it after a241 * user dismissal whenever background sync is still enabled.242 */243class SyncNotificationDismissedReceiver : BroadcastReceiver() {244    override fun onReceive(context: Context, intent: Intent?) {245        if (!shouldRestoreSyncNotification(246                intent?.action,247                BackgroundSyncPreference.isEnabled(context),248            )249        ) {250            return251        }252        val manager =253            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager254        manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))255    }256}257258/**259 * Every sync path asks for this service first, so a throw here is a sync that260 * never happened — and it used to take the caller with it: a crash from the261 * pairing and unlock screens, nothing at all from the fire-and-forget262 * coroutines. Returns false when the start was refused, having said why.263 *264 * [IllegalStateException] rather than API 31's265 * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a266 * subclass of this one and does not exist on this app's minSdk, and 26..30267 * throws the plain one for the same background start. Catching the supertype268 * covers both without an SDK_INT guard around a class that may not be there.269 */270internal fun startOrReportBlocked(271    report: (SyncOutcomeSummary) -> Unit,272    start: () -> Unit,273): Boolean = try {274    start()275    true276} catch (blocked: IllegalStateException) {277    report(278        SyncOutcomeSummary(279            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,280            detail = blocked.message,281        ),282    )283    false284} catch (error: Exception) {285    // Nothing about asking for a service is worth crashing the app over; the286    // reason is the only part of this a person can act on.287    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))288    false289}290291/**292 * [PeachSyncService.start] is called from five screens and coordinators that293 * hold no injector of their own, so the store is fetched here rather than294 * threaded through every one of them. Wrapped because a store that cannot even295 * be opened must not become the crash the guard above exists to prevent.296 */297private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {298    runCatching {299        EntryPointAccessors300            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)301            .syncOutcomes()302            .report(summary)303    }304}305306@EntryPoint307@InstallIn(SingletonComponent::class)308internal interface SyncOutcomeEntryPoint {309    fun syncOutcomes(): SyncOutcomeStore310}