PeachSyncService.kt

6.1 KB · raw

1package com.peachpasswords.sync23import android.app.Notification4import android.app.NotificationChannel5import android.app.NotificationManager6import android.app.PendingIntent7import android.app.Service8import android.content.Context9import android.content.Intent10import android.os.Build11import android.os.IBinder12import androidx.core.app.NotificationCompat13import com.peachpasswords.MainActivity14import com.peachpasswords.R15import dagger.hilt.android.AndroidEntryPoint16import com.peachpasswords.vault.VaultCrypto17import com.peachpasswords.vault.VaultRepository18import kotlinx.coroutines.CoroutineScope19import kotlinx.coroutines.SupervisorJob20import kotlinx.coroutines.cancel21import javax.inject.Inject2223/**24 * Keeps this device reachable on the relay so an already-paired peer can25 * reconnect. A browser extension can never listen, so if the phone is not26 * registered the two devices simply never sync and neither shows an error —27 * "no connection" and "nothing to sync" look identical.28 *29 * A browser extension can never listen for a connection, so the phone has to be30 * the side that is dialable — and a phone that is only dialable while its app31 * is foregrounded is not dialable in any useful sense. Doze and App Standby32 * will take an ordinary background socket away. This is the same standard33 * pattern Syncthing and Tailscale use for exactly the same reason: a foreground34 * service with a visible, honest notification.35 *36 * The notification is deliberately plain about what it is. A persistent37 * notification the user does not understand gets the app force-stopped, which38 * is the one outcome that breaks sync entirely.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: SyncAlertStore4849    private val scope = CoroutineScope(SupervisorJob())5051    override fun onBind(intent: Intent?): IBinder? = null5253    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {54        startForeground(NOTIFICATION_ID, buildNotification())55        startReconnectListener()56        // A start command means something changed — a new pairing, a manual57        // "sync now", the app coming forward. Re-read the device list now58        // rather than on the next interval; the listener is already running59        // on every start but the first.60        cloudRelayListener.requestReconcile()61        // START_STICKY: if the system reclaims us under pressure, come back —62        // a listener that silently stays down looks identical to a peer that63        // has simply gone offline, and the user gets no signal either way.64        return START_STICKY65    }6667    override fun onDestroy() {68        cloudRelayListener.stop()69        scope.cancel()70        super.onDestroy()71    }7273    /**74     * Only possible while unlocked — the sync identity is derived from the75     * vault's root key, so a locked phone genuinely has nothing to listen with.76     * That is also why this is re-attempted on every start command rather than77     * once: the service may well come up before the user has unlocked.78     *79     * The early return is deliberately the one terminating path that reports no80     * outcome. Coming up locked is the normal boot order, not a failed sync;81     * reporting it would leave a red light waiting on the other side of the82     * unlock screen. The locked case a person can actually see is reported from83     * `SyncViewModel.syncNow`, and from a session that reaches a locked vault84     * mid-handshake.85     */86    private fun startReconnectListener() {87        val master = vault.syncMasterKey() ?: return88        try {89            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))90            val identity = buildLocalIdentity(this, master)91            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {92                if (!vault.isUnlocked.value) null else VaultSyncAdapter(93                    repo = vault,94                    ourDeviceId = getOrCreateDeviceId(this),95                    pairedDevices = deviceStore,96                    pendingVouched = pendingVouchedStore,97                    syncAlerts = syncAlertStore,98                )99            }100        } finally {101            VaultCrypto.zero(master)102        }103    }104105    private fun buildNotification(): Notification {106        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager107        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {108            manager.createNotificationChannel(109                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {110                    description = "Shown while Peach is reachable for encrypted device sync."111                    setShowBadge(false)112                },113            )114        }115        val open = PendingIntent.getActivity(116            this,117            0,118            Intent(this, MainActivity::class.java),119            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,120        )121        return NotificationCompat.Builder(this, CHANNEL_ID)122            .setContentTitle("Peach sync is active")123            .setContentText("Your other devices can reach this phone for encrypted sync.")124            .setSmallIcon(R.drawable.ic_launcher_monochrome)125            .setOngoing(true)126            .setPriority(NotificationCompat.PRIORITY_LOW)127            .setContentIntent(open)128            .build()129    }130131    companion object {132        private const val CHANNEL_ID = "peach_sync"133        private const val NOTIFICATION_ID = 4201134135        fun start(context: Context) {136            val intent = Intent(context, PeachSyncService::class.java)137            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {138                context.startForegroundService(intent)139            } else {140                context.startService(intent)141            }142        }143144        fun stop(context: Context) {145            context.stopService(Intent(context, PeachSyncService::class.java))146        }147    }148}