package com.peachpasswords.sync import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import com.peachpasswords.MainActivity import com.peachpasswords.R import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel /** * Keeps the pairing/sync listener reachable. * * A browser extension can never listen for a connection, so the phone has to be * the side that is dialable — and a phone that is only dialable while its app * is foregrounded is not dialable in any useful sense. Doze and App Standby * will take an ordinary background socket away. This is the same standard * pattern Syncthing and Tailscale use for exactly the same reason: a foreground * service with a visible, honest notification. * * The notification is deliberately plain about what it is. A persistent * notification the user does not understand gets the app force-stopped, which * is the one outcome that breaks sync entirely. */ @AndroidEntryPoint class PeachSyncService : Service() { private val scope = CoroutineScope(SupervisorJob()) override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground(NOTIFICATION_ID, buildNotification()) // START_STICKY: if the system reclaims us under pressure, come back — // a listener that silently stays down looks identical to a peer that // has simply gone offline, and the user gets no signal either way. return START_STICKY } override fun onDestroy() { scope.cancel() super.onDestroy() } private fun buildNotification(): Notification { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel( NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply { description = "Shown while Peach is reachable for syncing on your network." setShowBadge(false) }, ) } val open = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Peach sync is active") .setContentText("Your other devices can reach this phone on your network.") .setSmallIcon(R.drawable.ic_launcher_monochrome) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setContentIntent(open) .build() } companion object { private const val CHANNEL_ID = "peach_sync" private const val NOTIFICATION_ID = 4201 fun start(context: Context) { val intent = Intent(context, PeachSyncService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { context.startService(intent) } } fun stop(context: Context) { context.stopService(Intent(context, PeachSyncService::class.java)) } } }