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 javax.inject.Inject2728/**29 * Keeps this device reachable on its deterministic relay waiter edges and30 * drives its dialer edges. Every supported client now uses the same public-key31 * role ordering, but Android still needs a foreground service: Doze and App32 * Standby will take an ordinary background socket away.33 *34 * The notification is deliberately plain about what it is. A persistent35 * notification the user does not understand gets the app force-stopped.36 */37@AndroidEntryPoint38class PeachSyncService : Service() {3940    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener41    @Inject lateinit var vault: VaultRepository42    @Inject lateinit var deviceStore: PairedDeviceStore43    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore44    @Inject lateinit var syncAlertStore: SyncAlertStore45    @Inject lateinit var syncOutcomes: SyncOutcomeStore4647    private val scope = CoroutineScope(SupervisorJob())4849    override fun onBind(intent: Intent?): IBinder? = null5051    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {52        // The same restriction that can refuse [start] applies again here, and53        // a START_STICKY restart is exactly when it bites: the system brings us54        // back while the app is in the background. A service that stays up55        // without its notification is killed a second later by a different56        // exception, so stop deliberately and leave the reported reason57        // standing rather than come back to be refused every time.58        if (!startOrReportBlocked(syncOutcomes::report) {59                startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))60            }61        ) {62            stopSelf()63            return START_NOT_STICKY64        }65        val listenerStarted = startReconnectListener()66        // A start command means something changed — a new pairing, a manual67        // "sync now", the app coming forward. Re-read the device list now68        // rather than on the next interval; the listener is already running69        // on every start but the first.70        // Replace existing waiters as well as re-reading the device list. A71        // locally active coroutine does not prove its idle WebSocket still72        // routes through the relay, and "sync now" must recover that state.73        cloudRelayListener.requestReconcile(74            refreshExisting = shouldRefreshExistingWaiters(listenerStarted),75        )76        // START_STICKY: if the system reclaims us under pressure, come back —77        // a listener that silently stays down looks identical to a peer that78        // has simply gone offline, and the user gets no signal either way.79        return START_STICKY80    }8182    override fun onDestroy() {83        cloudRelayListener.stop()84        scope.cancel()85        super.onDestroy()86    }8788    /**89     * Only possible while unlocked — the sync identity is derived from the90     * vault's root key, so a locked phone genuinely has nothing to listen with.91     * That is also why this is re-attempted on every start command rather than92     * once: the service may well come up before the user has unlocked.93     *94     * The early return is deliberately the one terminating path that reports no95     * outcome. Coming up locked is the normal boot order, not a failed sync;96     * reporting it would leave a red light waiting on the other side of the97     * unlock screen. The locked case a person can actually see is reported from98     * `SyncViewModel.syncNow`, and from a session that reaches a locked vault99     * mid-handshake.100     */101    private fun startReconnectListener(): Boolean {102        val master = vault.syncMasterKey() ?: return false103        return try {104            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))105            val identity = buildLocalIdentity(this, master)106            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {107                if (!vault.isUnlocked.value) null else VaultSyncAdapter(108                    repo = vault,109                    ourDeviceId = getOrCreateDeviceId(this),110                    pairedDevices = deviceStore,111                    pendingVouched = pendingVouchedStore,112                    syncAlerts = syncAlertStore,113                )114            }115        } finally {116            VaultCrypto.zero(master)117        }118    }119120    companion object {121        fun start(context: Context) {122            val intent = Intent(context, PeachSyncService::class.java)123            startOrReportBlocked({ context.reportSyncOutcome(it) }) {124                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {125                    context.startForegroundService(intent)126                } else {127                    context.startService(intent)128                }129            }130        }131132        fun stop(context: Context) {133            context.stopService(Intent(context, PeachSyncService::class.java))134        }135    }136}137138internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted139140internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"141internal const val SYNC_NOTIFICATION_ID = 4201142internal const val ACTION_RESTORE_SYNC_NOTIFICATION =143    "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"144145internal fun buildSyncNotification(context: Context): Notification {146    val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager147    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {148        manager.createNotificationChannel(149            NotificationChannel(150                SYNC_NOTIFICATION_CHANNEL_ID,151                "Peach sync",152                NotificationManager.IMPORTANCE_LOW,153            ).apply {154                description = "Shown while Peach is reachable for encrypted device sync."155                setShowBadge(false)156            },157        )158    }159    val open = PendingIntent.getActivity(160        context,161        0,162        Intent(context, MainActivity::class.java),163        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,164    )165    val restore = PendingIntent.getBroadcast(166        context,167        0,168        Intent(context, SyncNotificationDismissedReceiver::class.java)169            .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),170        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,171    )172    return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)173        .setContentTitle("Peach sync is active")174        .setContentText("Your other devices can reach this phone for encrypted sync.")175        .setSmallIcon(R.drawable.ic_launcher_monochrome)176        .setOngoing(true)177        .setPriority(NotificationCompat.PRIORITY_LOW)178        .setContentIntent(open)179        .setDeleteIntent(restore)180        .build()181        .also {182            // FLAG_NO_CLEAR protects Clear all. Android 14 deliberately allows183            // users to swipe ongoing notifications individually, so the184            // deleteIntent above restores this status surface while the user185            // preference still says background sync is active.186            it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR187        }188}189190internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =191    action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled192193/**194 * Android 14 permits an ongoing foreground-service notification to be swiped195 * away while its service keeps running. Peach's notification is the only196 * visible indication that the phone remains reachable, so restore it after a197 * user dismissal whenever background sync is still enabled.198 */199class SyncNotificationDismissedReceiver : BroadcastReceiver() {200    override fun onReceive(context: Context, intent: Intent?) {201        if (!shouldRestoreSyncNotification(202                intent?.action,203                BackgroundSyncPreference.isEnabled(context),204            )205        ) {206            return207        }208        val manager =209            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager210        manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))211    }212}213214/**215 * Every sync path asks for this service first, so a throw here is a sync that216 * never happened — and it used to take the caller with it: a crash from the217 * pairing and unlock screens, nothing at all from the fire-and-forget218 * coroutines. Returns false when the start was refused, having said why.219 *220 * [IllegalStateException] rather than API 31's221 * `ForegroundServiceStartNotAllowedException` is deliberate: that class is a222 * subclass of this one and does not exist on this app's minSdk, and 26..30223 * throws the plain one for the same background start. Catching the supertype224 * covers both without an SDK_INT guard around a class that may not be there.225 */226internal fun startOrReportBlocked(227    report: (SyncOutcomeSummary) -> Unit,228    start: () -> Unit,229): Boolean = try {230    start()231    true232} catch (blocked: IllegalStateException) {233    report(234        SyncOutcomeSummary(235            reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,236            detail = blocked.message,237        ),238    )239    false240} catch (error: Exception) {241    // Nothing about asking for a service is worth crashing the app over; the242    // reason is the only part of this a person can act on.243    report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))244    false245}246247/**248 * [PeachSyncService.start] is called from five screens and coordinators that249 * hold no injector of their own, so the store is fetched here rather than250 * threaded through every one of them. Wrapped because a store that cannot even251 * be opened must not become the crash the guard above exists to prevent.252 */253private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {254    runCatching {255        EntryPointAccessors256            .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)257            .syncOutcomes()258            .report(summary)259    }260}261262@EntryPoint263@InstallIn(SingletonComponent::class)264internal interface SyncOutcomeEntryPoint {265    fun syncOutcomes(): SyncOutcomeStore266}