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 val listenerStarted = startReconnectListener()74 75 76 77 78 79 80 81 cloudRelayListener.requestReconcile(82 refreshExisting = shouldRefreshExistingWaiters(listenerStarted),83 )84 85 86 87 return START_STICKY88 }8990 override fun onDestroy() {91 cloudRelayListener.stop()92 scope.cancel()93 super.onDestroy()94 }9596 979899100101102103104105106107108109 private fun startReconnectListener(): Boolean {110 val master = vault.syncMasterKey() ?: return false111 return try {112 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))113 val identity = buildLocalIdentity(this, master)114 cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {115 if (!vault.isUnlocked.value) null else VaultSyncAdapter(116 repo = vault,117 ourDeviceId = getOrCreateDeviceId(this),118 pairedDevices = deviceStore,119 pendingVouched = pendingVouchedStore,120 syncAlerts = syncAlertStore,121 )122 }123 } finally {124 VaultCrypto.zero(master)125 }126 }127128 companion object {129 fun start(context: Context) {130 val intent = Intent(context, PeachSyncService::class.java)131 startOrReportBlocked({ context.reportSyncOutcome(it) }) {132 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {133 context.startForegroundService(intent)134 } else {135 context.startService(intent)136 }137 }138 }139140 fun stop(context: Context) {141 context.stopService(Intent(context, PeachSyncService::class.java))142 }143 }144}145146internal fun shouldRefreshExistingWaiters(listenerStarted: Boolean): Boolean = !listenerStarted147148internal const val SYNC_NOTIFICATION_CHANNEL_ID = "peach_sync"149internal const val SYNC_NOTIFICATION_ID = 4201150internal const val ACTION_RESTORE_SYNC_NOTIFICATION =151 "com.peachpasswords.action.RESTORE_SYNC_NOTIFICATION"152153internal fun buildSyncNotification(context: Context): Notification {154 val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager155 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {156 manager.createNotificationChannel(157 NotificationChannel(158 SYNC_NOTIFICATION_CHANNEL_ID,159 "Peach sync",160 NotificationManager.IMPORTANCE_LOW,161 ).apply {162 description = "Shown while Peach is reachable for encrypted device sync."163 setShowBadge(false)164 },165 )166 }167 val open = PendingIntent.getActivity(168 context,169 0,170 Intent(context, MainActivity::class.java),171 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,172 )173 val restore = PendingIntent.getBroadcast(174 context,175 0,176 Intent(context, SyncNotificationDismissedReceiver::class.java)177 .setAction(ACTION_RESTORE_SYNC_NOTIFICATION),178 PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,179 )180 return NotificationCompat.Builder(context, SYNC_NOTIFICATION_CHANNEL_ID)181 .setContentTitle("Peach sync is active")182 .setContentText("Your other devices can reach this phone for encrypted sync.")183 .setSmallIcon(R.drawable.ic_launcher_monochrome)184 .setOngoing(true)185 .setPriority(NotificationCompat.PRIORITY_LOW)186 .setContentIntent(open)187 .setDeleteIntent(restore)188 .build()189 .also {190 191 192 193 194 it.flags = it.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR195 }196}197198internal fun shouldRestoreSyncNotification(action: String?, backgroundSyncEnabled: Boolean): Boolean =199 action == ACTION_RESTORE_SYNC_NOTIFICATION && backgroundSyncEnabled200201202203204205206207class SyncNotificationDismissedReceiver : BroadcastReceiver() {208 override fun onReceive(context: Context, intent: Intent?) {209 if (!shouldRestoreSyncNotification(210 intent?.action,211 BackgroundSyncPreference.isEnabled(context),212 )213 ) {214 return215 }216 val manager =217 context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager218 manager.notify(SYNC_NOTIFICATION_ID, buildSyncNotification(context))219 }220}221222223224225226227228229230231232233234internal fun startOrReportBlocked(235 report: (SyncOutcomeSummary) -> Unit,236 start: () -> Unit,237): Boolean = try {238 start()239 true240} catch (blocked: IllegalStateException) {241 report(242 SyncOutcomeSummary(243 reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED,244 detail = blocked.message,245 ),246 )247 false248} catch (error: Exception) {249 250 251 report(SyncOutcomeSummary(reason = SyncOutcomeReason.RUN_FAILED, detail = error.message))252 false253}254255256257258259260261private fun Context.reportSyncOutcome(summary: SyncOutcomeSummary) {262 runCatching {263 EntryPointAccessors264 .fromApplication(applicationContext, SyncOutcomeEntryPoint::class.java)265 .syncOutcomes()266 .report(summary)267 }268}269270@EntryPoint271@InstallIn(SingletonComponent::class)272internal interface SyncOutcomeEntryPoint {273 fun syncOutcomes(): SyncOutcomeStore274}