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.Inject2728293031323334353637@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 53 54 55 56 57 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 67 68 69 70 71 72 73 cloudRelayListener.requestReconcile(74 refreshExisting = shouldRefreshExistingWaiters(listenerStarted),75 )76 77 78 79 return START_STICKY80 }8182 override fun onDestroy() {83 cloudRelayListener.stop()84 scope.cancel()85 super.onDestroy()86 }8788 8990919293949596979899100101 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 183 184 185 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 && backgroundSyncEnabled192193194195196197198199class 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}213214215216217218219220221222223224225226internal 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 242 243 report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))244 false245}246247248249250251252253private 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}