PeachSyncService.kt

10.9 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 javax.inject.Inject2829/**30 * Keeps this device reachable on its deterministic relay waiter edges and31 * drives its dialer edges. Every supported client now uses the same public-key32 * role ordering, but Android still needs a foreground service: Doze and App33 * Standby will take an ordinary background socket away.34 *35 * The notification is deliberately plain about what it is. A persistent36 * notification the user does not understand gets the app force-stopped.37 */38@AndroidEntryPoint39class PeachSyncService : Service() {4041    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener42    @Inject lateinit var vault: VaultRepository43    @Inject lateinit var deviceStore: PairedDeviceStore44    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore45    @Inject lateinit var syncAlertStore: SyncAlertStore46    @Inject lateinit var syncOutcomes: SyncOutcomeStore47    @Inject lateinit var lockedSyncSpool: LockedSyncSpoolStore4849    private val scope = CoroutineScope(SupervisorJob())5051    override fun onBind(intent: Intent?): IBinder? = null5253    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {54        // The same restriction that can refuse [start] applies again here, and55        // a START_STICKY restart is exactly when it bites: the system brings us56        // back while the app is in the background. A service that stays up57        // without its notification is killed a second later by a different58        // exception, so stop deliberately and leave the reported reason59        // standing rather than come back to be refused every time.60        if (!startOrReportBlocked(syncOutcomes::report) {61                startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))62            }63        ) {64            stopSelf()65            return START_NOT_STICKY66        }67        if (vault.isUnlocked.value) {68            scope.launch { runCatching { lockedSyncSpool.drainInto(vault) } }69        }70        val listenerStarted = startReconnectListener()71        // A start command means something changed — a new pairing, a manual72        // "sync now", the app coming forward. Re-read the device list now73        // rather than on the next interval; the listener is already running74        // on every start but the first.75        // Replace existing waiters as well as re-reading the device list. A76        // locally active coroutine does not prove its idle WebSocket still77        // routes through the relay, and "sync now" must recover that state.78        cloudRelayListener.requestReconcile(79            refreshExisting = shouldRefreshExistingWaiters(listenerStarted),80        )81        // START_STICKY: if the system reclaims us under pressure, come back —82        // a listener that silently stays down looks identical to a peer that83        // has simply gone offline, and the user gets no signal either way.84        return START_STICKY85    }8687    override fun onDestroy() {88        cloudRelayListener.stop()89        scope.cancel()90        super.onDestroy()91    }9293    /**94     * Uses the Keystore-encrypted transport identity after full lock. The spool95     * has no vault root and can only move opaque entry envelopes.96     *97     * The early return is deliberately the one terminating path that reports no98     * outcome. Coming up locked is the normal boot order, not a failed sync;99     * reporting it would leave a red light waiting on the other side of the100     * unlock screen. The locked case a person can actually see is reported from101     * `SyncViewModel.syncNow`, and from a session that reaches a locked vault102     * mid-handshake.103     */104    private fun startReconnectListener(): Boolean {105        val master = vault.syncMasterKey()106        return try {107            if (master != null) lockedSyncSpool.refresh(vault)108            val (keys, identity) = lockedSyncSpool.identity() ?: return false109            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {110                if (!vault.isUnlocked.value) lockedSyncSpool.adapter() else VaultSyncAdapter(111                    repo = vault,112                    ourDeviceId = getOrCreateDeviceId(this),113                    pairedDevices = deviceStore,114                    pendingVouched = pendingVouchedStore,115                    syncAlerts = syncAlertStore,116                )117            }118        } finally {119            master?.let(VaultCrypto::zero)120        }121    }122123    companion object {124        fun start(context: Context) {125            val intent = Intent(context, PeachSyncService::class.java)126            startOrReportBlocked({ context.reportSyncOutcome(it) }) {127                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {128                    context.startForegroundService(intent)129                } else {130                    context.startService(intent)131                }132            }133        }134135        fun stop(context: Context) {136            context.stopService(Intent(context, PeachSyncService::class.java))137        }138    }139}140141internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted142143internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"144internal const val SYNC_NOTIFICATION_ID = 4201145internal const val ACTION_RESTORE_SYNC_NOTIFICATION =146    "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"147148internal fun buildSyncNotification(context: Context): Notification {149    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager150    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {151        manager.createNotificationChannel(152            NotificationChannel(153                SYNC_NOTIFICATION_CHANNEL_ID,154                "Peach sync",155                NotificationManager.IMPORTANCE_LOW,156            ).apply {157                description = "Shown while Peach is reachable for encrypted device sync."158                setShowBadge(false)159            },160        )161    }162    val open = PendingIntent.getActivity(163        context,164        0,165        Intent(context, MainActivity::class.java),166        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,167    )168    val restore = PendingIntent.getBroadcast(169        context,170        0,171        Intent(context, SyncNotificationDismissedReceiver::class.java)172            .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),173        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,174    )175    return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)176        .setContentTitle("Peach sync is active")177        .setContentText("Your other devices can reach this phone for encrypted sync.")178        .setSmallIcon(R.drawable.ic_launcher_monochrome)179        .setOngoing(true)180        .setPriority(NotificationCompat.PRIORITY_LOW)181        .setContentIntent(open)182        .setDeleteIntent(restore)183        .build()184        .also {185            // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows186            // users to swipe ongoing notifications individually, so the187            // deleteIntent above restores this status surface while the user188            // preference still says background sync is active.189            it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR190        }191}192193internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =194    action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled195196/**197 * Android 14 permits an ongoing foreground-service notification to be swiped198 * away while its service keeps running. Peach's notification is the only199 * visible indication that the phone remains reachable, so restore it after a200 * user dismissal whenever background sync is still enabled.201 */202class SyncNotificationDismissedReceiver : BroadcastReceiver() {203    override fun onReceive(context: Context, intent: Intent?) {204        if (!shouldRestoreSyncNotification(205                intent?.action,206                BackgroundSyncPreference.isEnabled(context),207            )208        ) {209            return210        }211        val manager =212            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager213        manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))214    }215}216217/**218 * Every sync path asks for this service first, so a throw here is a sync that219 * never happened — and it used to take the caller with it: a crash from the220 * pairing and unlock screens, nothing at all from the fire-and-forget221 * coroutines. Returns false when the start was refused, having said why.222 *223 * [IllegalStateException] rather than API 31's224 * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a225 * subclass of this one and does not exist on this app's minSdk, and 26..30226 * throws the plain one for the same background start. Catching the supertype227 * covers both without an SDK_INT guard around a class that may not be there.228 */229internal fun startOrReportBlocked(230    report: (SyncOutcomeSummary) -> Unit,231    start: () -> Unit,232): Boolean = try {233    start()234    true235} catch (blocked: IllegalStateException) {236    report(237        SyncOutcomeSummary(238            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,239            detail = blocked.message,240        ),241    )242    false243} catch (error: Exception) {244    // Nothing about asking for a service is worth crashing the app over; the245    // reason is the only part of this a person can act on.246    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))247    false248}249250/**251 * [PeachSyncService.start] is called from five screens and coordinators that252 * hold no injector of their own, so the store is fetched here rather than253 * threaded through every one of them. Wrapped because a store that cannot even254 * be opened must not become the crash the guard above exists to prevent.255 */256private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {257    runCatching {258        EntryPointAccessors259            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)260            .syncOutcomes()261            .report(summary)262    }263}264265@EntryPoint266@InstallIn(SingletonComponent::class)267internal interface SyncOutcomeEntryPoint {268    fun syncOutcomes(): SyncOutcomeStore269}