PeachSyncService.kt

5.7 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 the pairing/sync listener reachable.25 *26 * A browser extension can never listen for a connection, so the phone has to be27 * the side that is dialable — and a phone that is only dialable while its app28 * is foregrounded is not dialable in any useful sense. Doze and App Standby29 * will take an ordinary background socket away. This is the same standard30 * pattern Syncthing and Tailscale use for exactly the same reason: a foreground31 * service with a visible, honest notification.32 *33 * The notification is deliberately plain about what it is. A persistent34 * notification the user does not understand gets the app force-stopped, which35 * is the one outcome that breaks sync entirely.36 */37@AndroidEntryPoint38class PeachSyncService : Service() {3940    @Inject lateinit var listener: SyncReconnectListener41    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener42    @Inject lateinit var vault: VaultRepository43    @Inject lateinit var deviceStore: PairedDeviceStore44    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore45    @Inject lateinit var syncAlertStore: SyncAlertStore4647    private val scope = CoroutineScope(SupervisorJob())4849    override fun onBind(intent: Intent?): IBinder? = null5051    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {52        startForeground(NOTIFICATION_ID, buildNotification())53        startReconnectListener()54        // START_STICKY: if the system reclaims us under pressure, come back —55        // a listener that silently stays down looks identical to a peer that56        // has simply gone offline, and the user gets no signal either way.57        return START_STICKY58    }5960    override fun onDestroy() {61        listener.stop()62        cloudRelayListener.stop()63        scope.cancel()64        super.onDestroy()65    }6667    /**68     * Only possible while unlocked — the sync identity is derived from the69     * vault's root key, so a locked phone genuinely has nothing to listen with.70     * That is also why this is re-attempted on every start command rather than71     * once: the service may well come up before the user has unlocked.72     */73    private fun startReconnectListener() {74        val master = vault.syncMasterKey() ?: return75        try {76            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))77            val identity = buildLocalIdentity(this, master)78            listener.start(scope, keys.secretKey, keys.publicKey, identity) {79                if (!vault.isUnlocked.value) null else VaultSyncAdapter(80                    repo = vault,81                    ourDeviceId = getOrCreateDeviceId(this),82                    pairedDevices = deviceStore,83                    pendingVouched = pendingVouchedStore,84                    syncAlerts = syncAlertStore,85                )86            }87            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {88                if (!vault.isUnlocked.value) null else VaultSyncAdapter(89                    repo = vault,90                    ourDeviceId = getOrCreateDeviceId(this),91                    pairedDevices = deviceStore,92                    pendingVouched = pendingVouchedStore,93                    syncAlerts = syncAlertStore,94                )95            }96        } finally {97            VaultCrypto.zero(master)98        }99    }100101    private fun buildNotification(): Notification {102        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager103        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {104            manager.createNotificationChannel(105                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {106                    description = "Shown while Peach is reachable for encrypted device sync."107                    setShowBadge(false)108                },109            )110        }111        val open = PendingIntent.getActivity(112            this,113            0,114            Intent(this, MainActivity::class.java),115            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,116        )117        return NotificationCompat.Builder(this, CHANNEL_ID)118            .setContentTitle("Peach sync is active")119            .setContentText("Your other devices can reach this phone for encrypted sync.")120            .setSmallIcon(R.drawable.ic_launcher_monochrome)121            .setOngoing(true)122            .setPriority(NotificationCompat.PRIORITY_LOW)123            .setContentIntent(open)124            .build()125    }126127    companion object {128        private const val CHANNEL_ID = "peach_sync"129        private const val NOTIFICATION_ID = 4201130131        fun start(context: Context) {132            val intent = Intent(context, PeachSyncService::class.java)133            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {134                context.startForegroundService(intent)135            } else {136                context.startService(intent)137            }138        }139140        fun stop(context: Context) {141            context.stopService(Intent(context, PeachSyncService::class.java))142        }143    }144}