PeachSyncService.kt

5.4 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        // START_STICKY: if the system reclaims us under pressure, come back —57        // a listener that silently stays down looks identical to a peer that58        // has simply gone offline, and the user gets no signal either way.59        return START_STICKY60    }6162    override fun onDestroy() {63        cloudRelayListener.stop()64        scope.cancel()65        super.onDestroy()66    }6768    /**69     * Only possible while unlocked — the sync identity is derived from the70     * vault's root key, so a locked phone genuinely has nothing to listen with.71     * That is also why this is re-attempted on every start command rather than72     * once: the service may well come up before the user has unlocked.73     */74    private fun startReconnectListener() {75        val master = vault.syncMasterKey() ?: return76        try {77            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))78            val identity = buildLocalIdentity(this, master)79            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {80                if (!vault.isUnlocked.value) null else VaultSyncAdapter(81                    repo = vault,82                    ourDeviceId = getOrCreateDeviceId(this),83                    pairedDevices = deviceStore,84                    pendingVouched = pendingVouchedStore,85                    syncAlerts = syncAlertStore,86                )87            }88        } finally {89            VaultCrypto.zero(master)90        }91    }9293    private fun buildNotification(): Notification {94        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager95        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {96            manager.createNotificationChannel(97                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {98                    description = "Shown while Peach is reachable for encrypted device sync."99                    setShowBadge(false)100                },101            )102        }103        val open = PendingIntent.getActivity(104            this,105            0,106            Intent(this, MainActivity::class.java),107            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,108        )109        return NotificationCompat.Builder(this, CHANNEL_ID)110            .setContentTitle("Peach sync is active")111            .setContentText("Your other devices can reach this phone for encrypted sync.")112            .setSmallIcon(R.drawable.ic_launcher_monochrome)113            .setOngoing(true)114            .setPriority(NotificationCompat.PRIORITY_LOW)115            .setContentIntent(open)116            .build()117    }118119    companion object {120        private const val CHANNEL_ID = "peach_sync"121        private const val NOTIFICATION_ID = 4201122123        fun start(context: Context) {124            val intent = Intent(context, PeachSyncService::class.java)125            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {126                context.startForegroundService(intent)127            } else {128                context.startService(intent)129            }130        }131132        fun stop(context: Context) {133            context.stopService(Intent(context, PeachSyncService::class.java))134        }135    }136}