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.Inject27282930313233343536373839404142434445@AndroidEntryPoint46class PeachSyncService : Service() {4748 @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener49 @Inject lateinit var vault: VaultRepository50 @Inject lateinit var deviceStore: PairedDeviceStore51 @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore52 @Inject lateinit var syncAlertStore: SyncAlertStore53 @Inject lateinit var syncOutcomes: SyncOutcomeStore5455 private val scope = CoroutineScope(SupervisorJob())5657 override fun onBind(intent: Intent?): IBinder? = null5859 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {60 61 62 63 64 65 66 if (!startOrReportBlocked(syncOutcomes::report) {67 startForeground(SYNC_NOTIFICATION_ID, buildSyncNotification(this))68 }69 ) {70 stopSelf()71 return START_NOT_STICKY72 }73 startReconnectListener()74 75 76 77 78 79 80 81 cloudRelayListener.requestReconcile(refreshExisting = true)82 83 84 85 return START_STICKY86 }8788 override fun onDestroy() {89 cloudRelayListener.stop()90 scope.cancel()91 super.onDestroy()92 }9394 9596979899100101102103104105106107 private fun startReconnectListener() {108 val master = vault.syncMasterKey() ?: return109 try {110 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))111 val identity = buildLocalIdentity(this, master)112 cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {113 if (!vault.isUnlocked.value) null else VaultSyncAdapter(114 repo = vault,115 ourDeviceId = getOrCreateDeviceId(this),116 pairedDevices = deviceStore,117 pendingVouched = pendingVouchedStore,118 syncAlerts = syncAlertStore,119 )120 }121 } finally {122 VaultCrypto.zero(master)123 }124 }125126 companion object {127 fun start(context: Context) {128 val intent = Intent(context, PeachSyncService::class.java)129 startOrReportBlocked({ context.reportSyncOutcome(it) }) {130 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {131 context.startForegroundService(intent)132 } else {133 context.startService(intent)134 }135 }136 }137138 fun stop(context: Context) {139 context.stopService(Intent(context, PeachSyncService::class.java))140 }141 }142}143144internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"145internal const val SYNC_NOTIFICATION_ID = 4201146internal const val ACTION_RESTORE_SYNC_NOTIFICATION =147 "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"148149internal fun buildSyncNotification(context: Context): Notification {150 val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager151 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {152 manager.createNotificationChannel(153 NotificationChannel(154 SYNC_NOTIFICATION_CHANNEL_ID,155 "Peach sync",156 NotificationManager.IMPORTANCE_LOW,157 ).apply {158 description = "Shown while Peach is reachable for encrypted device sync."159 setShowBadge(false)160 },161 )162 }163 val open = PendingIntent.getActivity(164 context,165 0,166 Intent(context, MainActivity::class.java),167 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,168 )169 val restore = PendingIntent.getBroadcast(170 context,171 0,172 Intent(context, SyncNotificationDismissedReceiver::class.java)173 .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),174 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,175 )176 return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)177 .setContentTitle("Peach sync is active")178 .setContentText("Your other devices can reach this phone for encrypted sync.")179 .setSmallIcon(R.drawable.ic_launcher_monochrome)180 .setOngoing(true)181 .setPriority(NotificationCompat.PRIORITY_LOW)182 .setContentIntent(open)183 .setDeleteIntent(restore)184 .build()185 .also {186 187 188 189 190 it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR191 }192}193194internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =195 action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled196197198199200201202203class SyncNotificationDismissedReceiver : BroadcastReceiver() {204 override fun onReceive(context: Context, intent: Intent?) {205 if (!shouldRestoreSyncNotification(206 intent?.action,207 BackgroundSyncPreference.isEnabled(context),208 )209 ) {210 return211 }212 val manager =213 context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager214 manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))215 }216}217218219220221222223224225226227228229230internal fun startOrReportBlocked(231 report: (SyncOutcomeSummary) -> Unit,232 start: () -> Unit,233): Boolean = try {234 start()235 true236} catch (blocked: IllegalStateException) {237 report(238 SyncOutcomeSummary(239 reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,240 detail = blocked.message,241 ),242 )243 false244} catch (error: Exception) {245 246 247 report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))248 false249}250251252253254255256257private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {258 runCatching {259 EntryPointAccessors260 .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)261 .syncOutcomes()262 .report(summary)263 }264}265266@EntryPoint267@InstallIn(SingletonComponent::class)268internal interface SyncOutcomeEntryPoint {269 fun syncOutcomes(): SyncOutcomeStore270}