PeachSyncService.kt

12.8 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.CancellationException24import kotlinx.coroutines.CoroutineScope25import kotlinx.coroutines.SupervisorJob26import kotlinx.coroutines.cancel27import kotlinx.coroutines.launch28import java.security.MessageDigest29import javax.inject.Inject3031/**32 * Keeps this device reachable on its deterministic relay waiter edges and33 * drives its dialer edges. Every supported client now uses the same public-key34 * role ordering, but Android still needs a foreground service: Doze and App35 * Standby will take an ordinary background socket away.36 *37 * The notification is deliberately plain about what it is. A persistent38 * notification the user does not understand gets the app force-stopped.39 */40@AndroidEntryPoint41class PeachSyncService : Service() {4243    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener44    @Inject lateinit var vault: VaultRepository45    @Inject lateinit var deviceStore: PairedDeviceStore46    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore47    @Inject lateinit var syncAlertStore: SyncAlertStore48    @Inject lateinit var syncOutcomes: SyncOutcomeStore49    @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore50    @Inject lateinit var syncGeneration: SyncGenerationStore5152    private val scope = CoroutineScope(SupervisorJob())5354    override fun onBind(intent: Intent?): IBinder? = null5556    /**57     * The reported reason when this device must not sync, or null when it may.58     * §11.3: RESET_REQUIRED and RESET_CONFIRMED block every session.59     */60    private fun syncRefusal(): SyncOutcomeSummary? =61        syncRefusalReason(syncGeneration, System.currentTimeMillis())62            ?.let { SyncOutcomeSummary(reason = it) }6364    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {65        // Refuse before foreground promotion: reset-required is an expected66        // outcome, not a lifecycle exception, and must not mint an identity.67        syncRefusal()?.let {68            syncOutcomes.report(it)69            stopSelf()70            return START_NOT_STICKY71        }72        // A decoy unlock has no sync identity. Stop before foreground promotion73        // so it cannot expose a notification or drain the locked spool.74        if (vault.isUnlocked.value && vault.syncVaultId() == null) {75            stopSelf()76            return START_NOT_STICKY77        }78        if (!startOrReportBlocked(syncOutcomes::report) {79            startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))80        }) {81            stopSelf()82            return START_NOT_STICKY83        }84        if (vault.isUnlocked.value) {85            scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }86        }87        val listenerStarted = startReconnectListener()88        cloudRelayListener.requestReconcile(89            refreshExisting = shouldRefreshExistingWaiters(listenerStarted),90        )91        return START_STICKY92    }9394    override fun onDestroy() {95        cloudRelayListener.stop()96        scope.cancel()97        super.onDestroy()98    }99100    /** Uses the Keystore-wrapped transport identity after full lock. */101    private fun startReconnectListener(): Boolean {102        val master = vault.syncMasterKey()103        var credentials: ReconnectListenerCredentials? = null104        return try {105            credentials = if (master != null) {106                lockedSyncSpool.refresh(vault)107                deriveUnlockedListenerCredentials(108                    credentials = loadOrCreateLocalIdentity(this),109                    deviceName = getDeviceName(this),110                    relayUrl = RelaySettingsStore.readRelayUrl(this),111                )112            } else {113                lockedSyncSpool.identity()?.let { ReconnectListenerCredentials(it.first, it.second) }114                    ?: return false115            }116            cloudRelayListener.start(117                scope,118                credentials.keys.secretKey,119                credentials.keys.publicKey,120                credentials.identity,121            ) {122                // A decoy session can replace a real unlock while an existing123                // relay callback is still being scheduled. Cancel that callback124                // without reporting a misleading locked-vault outcome.125                when {126                    vault.syncVaultId() == null -> throw CancellationException("decoy session")127                    // §11: a device waiting on, or part-way through, its reset opens128                    // no session at all — neither unlocked nor through the locked129                    // spool, whose staged ciphertext belongs to the retired130                    // generation and must not be applied.131                    else -> syncRefusal()?.let { refusal ->132                        syncOutcomes.report(refusal)133                        null134                    } ?: if (!vault.isUnlocked.value) {135                        lockedSyncSpool.adapter()136                    } else {137                        val credentials = loadOrCreateLocalIdentity(this)138                        VaultSyncAdapter(139                            repo = vault,140                            ourDeviceId = credentials.deviceId,141                            pairedDevices = deviceStore,142                            pendingVouched = pendingVouchedStore,143                            syncAlerts = syncAlertStore,144                            localCredentials = credentials,145                        )146                    }147                }148            }149        } finally {150            master?.let(VaultCrypto::zero)151            credentials?.keys?.secretKey?.let(VaultCrypto::zero)152        }153    }154155    companion object {156        fun start(context: Context) {157            val intent = Intent(context, PeachSyncService::class.java)158            startOrReportBlocked({ context.reportSyncOutcome(it) }) {159                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {160                    context.startForegroundService(intent)161                } else {162                    context.startService(intent)163                }164            }165        }166167        fun stop(context: Context) {168            context.stopService(Intent(context, PeachSyncService::class.java))169        }170    }171}172173internal data class ReconnectListenerCredentials(174    val keys: SyncIdentityKeypair,175    val identity: LocalDeviceIdentity,176)177178internal fun deriveUnlockedListenerCredentials(179    credentials: LocalIdentityCredentials,180    deviceName: String,181    relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,182): ReconnectListenerCredentials {183    val keys = localSyncKeypair(credentials)184    val identity = LocalDeviceIdentity(185        deviceId = credentials.deviceId,186        deviceName = deviceName,187        deviceType = DeviceType.ANDROID,188        longTermPublicKeyHex = pubkeyToHex(keys.publicKey),189        signingPublicKeyHex = pubkeyToHex(credentials.ed25519PublicKey),190        relayUrl = relayUrl,191    )192    return ReconnectListenerCredentials(keys, identity)193}194195internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted196197internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"198internal const val SYNC_NOTIFICATION_ID = 4201199internal const val ACTION_RESTORE_SYNC_NOTIFICATION =200    "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"201202internal fun buildSyncNotification(context: Context): Notification {203    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager204    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {205        manager.createNotificationChannel(206            NotificationChannel(207                SYNC_NOTIFICATION_CHANNEL_ID,208                "Peach sync",209                NotificationManager.IMPORTANCE_LOW,210            ).apply {211                description = "Shown while Peach is reachable for encrypted device sync."212                setShowBadge(false)213            },214        )215    }216    val open = PendingIntent.getActivity(217        context,218        0,219        Intent(context, MainActivity::class.java),220        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,221    )222    val restore = PendingIntent.getBroadcast(223        context,224        0,225        Intent(context, SyncNotificationDismissedReceiver::class.java)226            .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),227        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,228    )229    return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)230        .setContentTitle("Peach sync is on")231        .setContentText("Checking for encrypted changes from your paired devices.")232        .setSmallIcon(R.drawable.ic_launcher_monochrome)233        .setOngoing(true)234        .setPriority(NotificationCompat.PRIORITY_LOW)235        .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)236        .setContentIntent(open)237        .setDeleteIntent(restore)238        .build()239        .also {240            // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows241            // users to swipe ongoing notifications individually, so the242            // deleteIntent above restores this status surface while the user243            // preference still says background sync is active.244            it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR245        }246}247248internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =249    action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled250251/**252 * Android 14 permits an ongoing foreground-service notification to be swiped253 * away while its service keeps running. Peach's notification is the only254 * visible indication that the phone remains reachable, so restore it after a255 * user dismissal whenever background sync is still enabled.256 */257class SyncNotificationDismissedReceiver : BroadcastReceiver() {258    override fun onReceive(context: Context, intent: Intent?) {259        if (!shouldRestoreSyncNotification(260                intent?.action,261                BackgroundSyncPreference.isEnabled(context),262            )263        ) {264            return265        }266        val manager =267            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager268        manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))269    }270}271272/**273 * Every sync path asks for this service first, so a throw here is a sync that274 * never happened — and it used to take the caller with it: a crash from the275 * pairing and unlock screens, nothing at all from the fire-and-forget276 * coroutines. Returns false when the start was refused, having said why.277 *278 * [IllegalStateException] rather than API 31's279 * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a280 * subclass of this one and does not exist on this app's minSdk, and 26..30281 * throws the plain one for the same background start. Catching the supertype282 * covers both without an SDK_INT guard around a class that may not be there.283 */284internal fun startOrReportBlocked(285    report: (SyncOutcomeSummary) -> Unit,286    start: () -> Unit,287): Boolean = try {288    start()289    true290} catch (blocked: IllegalStateException) {291    report(292        SyncOutcomeSummary(293            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,294            detail = blocked.message,295        ),296    )297    false298} catch (error: Exception) {299    // Nothing about asking for a service is worth crashing the app over; the300    // reason is the only part of this a person can act on.301    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))302    false303}304305/**306 * [PeachSyncService.start] is called from five screens and coordinators that307 * hold no injector of their own, so the store is fetched here rather than308 * threaded through every one of them. Wrapped because a store that cannot even309 * be opened must not become the crash the guard above exists to prevent.310 */311private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {312    runCatching {313        EntryPointAccessors314            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)315            .syncOutcomes()316            .report(summary)317    }318}319320@EntryPoint321@InstallIn(SingletonComponent::class)322internal interface SyncOutcomeEntryPoint {323    fun syncOutcomes(): SyncOutcomeStore324}