package com.peachpasswords.sync import org.junit.Assert.assertEquals import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertTrue import org.junit.Test /** * The service start is the first step of every sync path, and it was the one * step that could throw straight out of a button press: Android 12+ refuses a * foreground service started from the background, and `startForegroundService` * was called bare. */ class SyncServiceStartTest { /** The real thing on API 31+, whose class is not on this app's minSdk. */ private fun backgroundStartRefused(): IllegalStateException = IllegalStateException( "Not allowed to start service Intent { cmp=com.peachpasswords/.sync.PeachSyncService }: " + "app is in background", ) @Test fun `a start Android refuses is reported, not thrown`() { val reported = mutableListOf() val started = startOrReportBlocked({ reported += it }) { throw backgroundStartRefused() } assertEquals(false, started) assertEquals(listOf(SyncOutcomeReason.FOREGROUND_START_BLOCKED), reported.map { it.reason }) assertTrue(reported.single().detail?.contains("app is in background") == true) } @Test fun `the refusal says what the person holding the phone can do`() { assertEquals( "Android blocked Peach from starting sync in the background — open Peach and sync again", describeSyncOutcome(SyncOutcomeSummary(reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED)), ) assertEquals( SyncIndicator.ERROR, syncIndicator( SyncOutcomeSummary(reason = SyncOutcomeReason.FOREGROUND_START_BLOCKED), pairedCount = 1, ), ) } /** A start that fails some other way is still a sync that did not happen. */ @Test fun `anything else the platform throws is reported too, and never escapes`() { val reported = mutableListOf() val started = startOrReportBlocked({ reported += it }) { throw SecurityException("missing FOREGROUND_SERVICE_DATA_SYNC") } assertEquals(false, started) assertEquals(listOf(SyncOutcomeReason.RUN_FAILED), reported.map { it.reason }) } @Test fun `a start that works reports nothing`() { val reported = mutableListOf() var ran = false assertEquals(true, startOrReportBlocked({ reported += it }) { ran = true }) assertTrue(ran) assertTrue(reported.isEmpty()) } @Test fun `only a nudge to an existing listener replaces its waiters`() { assertEquals(false, shouldRefreshExistingWaiters(listenerStarted = true)) assertEquals(true, shouldRefreshExistingWaiters(listenerStarted = false)) } @Test fun `an unlocked vault derives transient listener credentials`() { val credentials = randomLocalIdentityCredentials("phone-id") val expected = localSyncKeypair(credentials) val actual = deriveUnlockedListenerCredentials(credentials, "Phone") assertEquals("phone-id", actual.identity.deviceId) assertEquals("Phone", actual.identity.deviceName) assertEquals(pubkeyToHex(expected.publicKey), actual.identity.longTermPublicKeyHex) assertArrayEquals(expected.publicKey, actual.keys.publicKey) assertArrayEquals(expected.secretKey, actual.keys.secretKey) } }