diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index 6b88a8c79..5b4acf49c 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -90,6 +90,13 @@ verification 모듈의 수동 QA 시나리오는 `VERIFICATION_TEST_PLAN.md` 를 수동 Trace API만 사용하므로 HTTP 자동 계측·`@AddTrace`용 Performance Gradle 플러그인은 추가하지 않는다. - 미검증: SDK 수집 비활성화·샘플링과 장시간 오프라인 후의 서버 수신은 이번 확인 범위에 포함하지 않았다. +### 2026-10-29 지오펜스 재등록 (#641) + +- `GeofenceReconcileTest`: 변경 없는 대상의 중복 등록 방지, 좌표·반경·체류 시간 변경, 등록·삭제 실패 후 재시도와 권한 복구를 검증한다. 변경된 장소의 재등록에 실패한 뒤 기존 등록 기록을 보존하는지도 확인한다. +- `GeofenceRegistrationRecoveryTest`: 앱 업데이트, 사용자 종료 및 수신 PendingIntent 소실 뒤 기존 등록 기록을 무효화하고, 일반 프로세스 재시작은 재등록하지 않는 조건을 확인한다. +- `GeofenceRecoveryBroadcastTest`: 부팅과 Google Play 서비스 데이터 초기화만 복구 대상으로 판정한다. +- 오프라인 Android 에뮬레이터에서 실제 지오펜스 등록 및 중복 등록 방지, Google Play 서비스 데이터 초기화 후 재시도 상태와 위치 정확도 복구 후 재등록을 확인했다. 1분 체류 설정에서 ENTER 직후 DWELL은 없었고, 연속 GPS 측정 시 기록된 두 신호의 시각 차이는 79,258ms였다. 초기 ENTER는 캐시된 위치의 영향을 받았을 수 있으며, GPS 측정이 중단된 별도 시도에서는 3분 내 DWELL이 오지 않았다. 별도 테스트 앱으로 일반 종료 시 PendingIntent 유지, 강제 종료 시 제거, 업데이트 감지 신호도 확인했다. 물리 기기에서의 위치 이동과 체류 판정은 별도 확인해야 한다. + ### 2026-09-28 권한·동의 및 안내 모달 통합 검증 (#504 · #505) 현황 표는 2026-09-28 다시 집계했다. 이번 변경에서는 presentation의 `SensitiveConsentTest`를 diff --git a/verification/data/src/main/AndroidManifest.xml b/verification/data/src/main/AndroidManifest.xml index 3e6940baf..7c58b3c21 100644 --- a/verification/data/src/main/AndroidManifest.xml +++ b/verification/data/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ + @@ -40,13 +41,17 @@ android:name="com.ruleup.verification.data.signal.geofence.GeofenceBroadcastReceiver" android:exported="false" /> - + + + + + diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/settings/VerificationSettingsStore.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/settings/VerificationSettingsStore.kt index b8e44084f..f57daa837 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/settings/VerificationSettingsStore.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/settings/VerificationSettingsStore.kt @@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.first +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Qualifier @@ -47,6 +49,19 @@ class VerificationSettingsStore dataStore.edit { it[KEY_LAST_COVERED_UNTIL] = at } } + suspend fun registeredGeofences(): Map = + dataStore.data.first()[KEY_REGISTERED_GEOFENCES]?.let { raw -> + runCatching { Json.decodeFromString>(raw) }.getOrNull() + } ?: emptyMap() + + suspend fun setRegisteredGeofences(registered: Map) { + dataStore.edit { it[KEY_REGISTERED_GEOFENCES] = Json.encodeToString(registered) } + } + + suspend fun invalidateRegisteredGeofences() { + setRegisteredGeofences(registeredGeofences().mapValues { "" }) + } + suspend fun lastGeofenceReregisterAt(): Long? = dataStore.data.first()[KEY_LAST_GEOFENCE_REREGISTER_AT] suspend fun setLastGeofenceReregisterAt(at: Long) { @@ -70,6 +85,7 @@ class VerificationSettingsStore val KEY_BOOT_EPOCH_ANCHOR = longPreferencesKey("boot_epoch_anchor") val KEY_LAST_FLUSH_AT = longPreferencesKey("last_successful_flush_at") val KEY_LAST_COVERED_UNTIL = longPreferencesKey("last_covered_until") + val KEY_REGISTERED_GEOFENCES = stringPreferencesKey("registered_geofences") val KEY_LAST_GEOFENCE_REREGISTER_AT = longPreferencesKey("last_geofence_reregister_at") val KEY_FLUSH_INTERVAL_SEC = longPreferencesKey("flush_interval_sec") } diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/BootReceiver.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/BootReceiver.kt index 9866dad4d..a3f7185c2 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/BootReceiver.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/BootReceiver.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject -/** 재부팅 후 지오펜스 재등록. */ +/** OS 등록 유실 시 지오펜스 복원. */ @AndroidEntryPoint class BootReceiver : BroadcastReceiver() { @Inject @@ -20,13 +20,12 @@ class BootReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - // LOCKED_BOOT_COMPLETED 는 받지 않는다 - if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + if (!requiresGeofenceRecovery(intent.action, intent.data?.schemeSpecificPart)) return val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { try { - geofenceRegister.reconcilePersisted() + geofenceRegister.reconcilePersisted(forceReregister = true) } catch (t: Throwable) { // 재등록 실패는 다음 콜드스타트 reconcile 이 보정한다. } finally { @@ -35,3 +34,10 @@ class BootReceiver : BroadcastReceiver() { } } } + +internal fun requiresGeofenceRecovery( + action: String?, + packageName: String?, +): Boolean = + action == Intent.ACTION_BOOT_COMPLETED || + (action == Intent.ACTION_PACKAGE_DATA_CLEARED && packageName == "com.google.android.gms") diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceBroadcastReceiver.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceBroadcastReceiver.kt index b0120769b..8518459f0 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceBroadcastReceiver.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceBroadcastReceiver.kt @@ -5,11 +5,13 @@ import android.content.Context import android.content.Intent import android.os.SystemClock import com.google.android.gms.location.Geofence +import com.google.android.gms.location.GeofenceStatusCodes import com.google.android.gms.location.GeofencingEvent import com.ruleup.verification.data.db.common.verificationDatabase import com.ruleup.verification.data.db.geofence.GeofenceTransitionEntity import com.ruleup.verification.data.sync.VerificationSyncSchedulerImpl import com.ruleup.verification.domain.entity.GeofenceTransitionType +import com.ruleup.verification.domain.repository.GeofenceRegister import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -23,12 +25,29 @@ class GeofenceBroadcastReceiver : BroadcastReceiver() { @javax.inject.Inject lateinit var tokens: com.ruleup.domain.token.TokenRepository + @javax.inject.Inject + lateinit var geofenceRegister: GeofenceRegister + override fun onReceive( context: Context, intent: Intent, ) { val event = GeofencingEvent.fromIntent(intent) ?: return - if (event.hasError()) return + if (event.hasError()) { + if (event.errorCode == GeofenceStatusCodes.GEOFENCE_NOT_AVAILABLE) { + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + geofenceRegister.reconcilePersisted(forceReregister = true) + } catch (_: Exception) { + // 다음 동기화에서 등록을 다시 시도한다. + } finally { + pending.finish() + } + } + } + return + } val type = geofenceTransitionTypeOfCode(event.geofenceTransition) ?: return val fences = event.triggeringGeofences ?: return diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofencePendingIntent.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofencePendingIntent.kt index f3a2b0743..5c60af3e4 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofencePendingIntent.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofencePendingIntent.kt @@ -8,15 +8,23 @@ import android.os.Build internal const val ACTION_GEOFENCE_EVENT = "com.ruleup.verification.GEOFENCE_EVENT" /** 지오펜스 전이를 받을 PendingIntent. */ -internal fun geofencePendingIntent(context: Context): PendingIntent { - val intent = - Intent(context.applicationContext, GeofenceBroadcastReceiver::class.java) - .setAction(ACTION_GEOFENCE_EVENT) - val mutable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 - return PendingIntent.getBroadcast( +internal fun geofencePendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( context.applicationContext, 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or mutable, + geofenceIntent(context), + PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(), ) -} + +internal fun hasGeofencePendingIntent(context: Context): Boolean = + PendingIntent.getBroadcast( + context.applicationContext, + 0, + geofenceIntent(context), + PendingIntent.FLAG_NO_CREATE or mutableFlag(), + ) != null + +private fun geofenceIntent(context: Context): Intent = + Intent(context.applicationContext, GeofenceBroadcastReceiver::class.java).setAction(ACTION_GEOFENCE_EVENT) + +private fun mutableFlag(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegisterImpl.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegisterImpl.kt index 4a0d87381..742d26c4f 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegisterImpl.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegisterImpl.kt @@ -1,7 +1,10 @@ package com.ruleup.verification.data.signal.geofence import android.annotation.SuppressLint +import android.app.ActivityManager +import android.app.ApplicationExitInfo import android.content.Context +import android.os.Build import com.google.android.gms.location.Geofence import com.google.android.gms.location.GeofencingRequest import com.google.android.gms.location.LocationServices @@ -14,6 +17,8 @@ import com.ruleup.verification.domain.entity.GapReason import com.ruleup.verification.domain.entity.GeofenceTarget import com.ruleup.verification.domain.repository.GeofenceRegister import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -29,72 +34,81 @@ class GeofenceRegisterImpl ) : GeofenceRegister { private val client by lazy { LocationServices.getGeofencingClient(context.applicationContext) } - override suspend fun reconcile(targets: List) { - // 권한 없으면 OS 등록 불가 → 목표만 보존하고 종료(허용 후 reconcile 이 재시도). - if (!context.hasFineLocation()) { - persist(targets) - if (targets.isNotEmpty()) recordNotRegistered() - return - } + private val lock = Mutex() - val previous = geofenceTargetDao.all().mapTo(HashSet()) { it.requestId } - val toRemove = GeofenceReconcile.toRemove(previous, targets) - if (toRemove.isNotEmpty()) { - runCatching { client.removeGeofences(toRemove).await() } - } - if (targets.isNotEmpty()) { - registerAll(targets) + private val mostRecentUserStopAt: Long? by lazy { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + null + } else { + try { + context + .getSystemService(ActivityManager::class.java) + .getHistoricalProcessExitReasons(null, 0, 0) + .filter { it.reason == ApplicationExitInfo.REASON_USER_REQUESTED } + .maxOfOrNull { it.timestamp } + } catch (_: SecurityException) { + System.currentTimeMillis() + } catch (_: IllegalArgumentException) { + System.currentTimeMillis() + } } - persist(targets) } - override suspend fun reconcilePersisted() { - reconcile(geofenceTargetDao.all().map { it.toDomain() }) - } + private val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { settingsStore.registeredGeofences() }, + writeRegistered = { settingsStore.setRegisteredGeofences(it) }, + add = { registerAll(it) }, + remove = { ids -> runCatching { client.removeGeofences(ids).await() }.isSuccess }, + ) + + override suspend fun reconcile(targets: List) = lock.withLock { reconcileLocked(targets) } + + override suspend fun reconcilePersisted(forceReregister: Boolean) = + lock.withLock { + if (forceReregister) settingsStore.invalidateRegisteredGeofences() + reconcileLocked(geofenceTargetDao.all().map { it.toDomain() }) + } override suspend fun bind( requestIdPrefix: String, targets: List, - ) { - // 이 멤버(prefix) 소속 기존 펜스 중 새 목록에 없는 것만 해제 - val newIds = targets.mapTo(HashSet()) { it.requestId } - val stale = - geofenceTargetDao - .byRequestIdPrefix(requestIdPrefix) - .map { it.requestId } - .filterNot { it in newIds } - if (stale.isNotEmpty()) { - runCatching { client.removeGeofences(stale).await() } - } - if (targets.isNotEmpty()) { - if (context.hasFineLocation()) registerAll(targets) else recordNotRegistered() - } - geofenceTargetDao.deleteByRequestIdPrefix(requestIdPrefix) - if (targets.isNotEmpty()) geofenceTargetDao.upsertAll(targets.map { it.toEntity() }) + ) = lock.withLock { + val retained = geofenceTargetDao.all().map { it.toDomain() }.filterNot { it.requestId.startsWith(requestIdPrefix) } + reconcileLocked(retained + targets) } - override suspend fun unbind(requestIdPrefix: String) { - val ids = geofenceTargetDao.byRequestIdPrefix(requestIdPrefix).map { it.requestId } - if (ids.isNotEmpty()) { - runCatching { client.removeGeofences(ids).await() } + override suspend fun unbind(requestIdPrefix: String) = + lock.withLock { + val retained = geofenceTargetDao.all().map { it.toDomain() }.filterNot { it.requestId.startsWith(requestIdPrefix) } + reconcileLocked(retained) } - geofenceTargetDao.deleteByRequestIdPrefix(requestIdPrefix) - } - override suspend fun clear() { - val ids = geofenceTargetDao.all().map { it.requestId } - if (ids.isNotEmpty()) { - runCatching { client.removeGeofences(ids).await() } + override suspend fun clear() = lock.withLock { reconcileLocked(emptyList()) } + + private suspend fun reconcileLocked(targets: List) { + val lastRegisteredAt = settingsStore.lastGeofenceReregisterAt() + + @Suppress("DEPRECATION") + val lastPackageUpdateAt = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime + if (needsRegistrationRecovery(lastRegisteredAt, lastPackageUpdateAt, mostRecentUserStopAt, hasGeofencePendingIntent(context))) { + settingsStore.invalidateRegisteredGeofences() } - geofenceTargetDao.clear() + persist(targets) + if (!context.hasFineLocation()) { + settingsStore.invalidateRegisteredGeofences() + if (targets.isNotEmpty()) recordNotRegistered() + coordinator.reconcile(targets, canRegister = false) + return + } + coordinator.reconcile(targets) } - // 등록 성공 시 시각 저장, 실패 시 GEOFENCE_NOT_REGISTERED 기록. - private suspend fun registerAll(targets: List) { + private suspend fun registerAll(targets: List): Boolean = runCatching { client.addGeofences(buildRequest(targets), geofencePendingIntent(context)).await() } .onSuccess { settingsStore.setLastGeofenceReregisterAt(System.currentTimeMillis()) } .onFailure { recordNotRegistered() } - } + .isSuccess private suspend fun recordNotRegistered() { val now = System.currentTimeMillis() @@ -133,7 +147,7 @@ class GeofenceRegisterImpl return GeofencingRequest .Builder() .setInitialTrigger( - GeofencingRequest.INITIAL_TRIGGER_ENTER or GeofencingRequest.INITIAL_TRIGGER_DWELL, + GeofencingRequest.INITIAL_TRIGGER_ENTER, ).addGeofences(fences) .build() } diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationCoordinator.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationCoordinator.kt new file mode 100644 index 000000000..c70e83391 --- /dev/null +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationCoordinator.kt @@ -0,0 +1,39 @@ +package com.ruleup.verification.data.signal.geofence + +import com.ruleup.verification.domain.entity.GeofenceTarget +import java.nio.ByteBuffer +import java.security.MessageDigest + +internal class GeofenceRegistrationCoordinator( + private val readRegistered: suspend () -> Map, + private val writeRegistered: suspend (Map) -> Unit, + private val add: suspend (List) -> Boolean, + private val remove: suspend (List) -> Boolean, +) { + suspend fun reconcile( + targets: List, + canRegister: Boolean = true, + ) { + val registered = readRegistered().toMutableMap() + val fingerprints = targets.associate { it.requestId to it.fingerprint() } + val removed = registered.keys - fingerprints.keys + if (removed.isNotEmpty() && remove(removed.toList())) registered.keys.removeAll(removed) + val changed = targets.filter { registered[it.requestId] != fingerprints[it.requestId] } + if (canRegister && changed.isNotEmpty() && add(changed)) { + changed.forEach { registered[it.requestId] = fingerprints.getValue(it.requestId) } + } + writeRegistered(registered) + } +} + +private fun GeofenceTarget.fingerprint(): String { + val data = + ByteBuffer + .allocate(24) + .putDouble(lat) + .putDouble(lng) + .putFloat(radiusM) + .putInt(dwellMinutes) + .array() + return MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) } +} diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationRecovery.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationRecovery.kt new file mode 100644 index 000000000..6679cbd48 --- /dev/null +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationRecovery.kt @@ -0,0 +1,15 @@ +package com.ruleup.verification.data.signal.geofence + +// GeofencingClient는 앱 업데이트·강제 종료 시 OS 등록을 제거할 수 있다. +internal fun needsRegistrationRecovery( + lastRegisteredAt: Long?, + lastPackageUpdateAt: Long, + lastUserStopAt: Long?, + hasPendingIntent: Boolean, +): Boolean = + lastRegisteredAt != null && + ( + lastPackageUpdateAt > lastRegisteredAt || + (lastUserStopAt != null && lastUserStopAt > lastRegisteredAt) || + !hasPendingIntent + ) diff --git a/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceReconcileTest.kt b/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceReconcileTest.kt index 31d320b63..93835dc6b 100644 --- a/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceReconcileTest.kt +++ b/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceReconcileTest.kt @@ -1,6 +1,7 @@ package com.ruleup.verification.data.signal.geofence import com.ruleup.verification.domain.entity.GeofenceTarget +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -32,5 +33,194 @@ class GeofenceReconcileTest { assertTrue(toRemove.isEmpty()) } + @Test + fun `같은 지오펜스로 반복 동기화하면 등록은 한 번만 한다`() = + runBlocking { + val registered = mutableMapOf() + val added = mutableListOf>() + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { + added += it + true + }, + remove = { true }, + ) + val desired = listOf(target("a"), target("b")) + + coordinator.reconcile(desired) + coordinator.reconcile(desired) + + assertEquals(listOf(desired), added) + } + + @Test + fun `등록에 실패한 지오펜스는 다음 동기화에서 다시 시도한다`() = + runBlocking { + val registered = mutableMapOf() + var calls = 0 + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { ++calls > 1 }, + remove = { true }, + ) + + coordinator.reconcile(listOf(target("a"))) + coordinator.reconcile(listOf(target("a"))) + + assertEquals(2, calls) + } + + @Test + fun `일부 장소만 바뀌면 바뀐 지오펜스만 다시 등록한다`() = + runBlocking { + val registered = mutableMapOf() + val added = mutableListOf>() + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { + added += it + true + }, + remove = { true }, + ) + val unchanged = target("a") + val updated = target("b").copy(radiusM = 200f) + coordinator.reconcile(listOf(unchanged, target("b"))) + coordinator.reconcile(listOf(unchanged, updated)) + + assertEquals(listOf(updated), added.last()) + } + + @Test + fun `변경된 장소 등록에 실패하면 기존 등록 기록을 보존하고 변경이 유지될 때 재시도한다`() = + runBlocking { + val registered = mutableMapOf() + val added = mutableListOf>() + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { + added += it + added.size != 2 + }, + remove = { true }, + ) + val original = target("a") + val changed = original.copy(radiusM = 200f) + + coordinator.reconcile(listOf(original)) + val originalRegistration = registered.toMap() + coordinator.reconcile(listOf(changed)) + assertEquals(originalRegistration, registered) + coordinator.reconcile(listOf(original)) + assertEquals(2, added.size) + coordinator.reconcile(listOf(changed)) + + assertEquals(listOf(listOf(original), listOf(changed), listOf(changed)), added) + assertTrue(originalRegistration != registered) + } + + @Test + fun `권한이 없을 때는 등록하지 않고 복구되면 미확인 장소를 등록한다`() = + runBlocking { + val registered = mutableMapOf("a" to "") + var calls = 0 + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { + calls++ + true + }, + remove = { true }, + ) + + coordinator.reconcile(listOf(target("a")), canRegister = false) + assertEquals(0, calls) + coordinator.reconcile(listOf(target("a"))) + assertEquals(1, calls) + } + + @Test + fun `삭제에 실패한 지오펜스는 다음 동기화에서 삭제를 재시도한다`() = + runBlocking { + val registered = mutableMapOf("a" to "old") + var calls = 0 + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { true }, + remove = { ++calls > 1 }, + ) + + coordinator.reconcile(emptyList()) + assertEquals(setOf("a"), registered.keys) + coordinator.reconcile(emptyList()) + assertTrue(registered.isEmpty()) + assertEquals(2, calls) + } + + @Test + fun `좌표와 반경과 체류 시간 변경은 각각 새 등록으로 반영된다`() = + runBlocking { + val original = target("a") + val changes = + listOf( + original.copy(lat = 1.0), + original.copy(lng = 1.0), + original.copy(radiusM = 200f), + original.copy(dwellMinutes = 30), + ) + changes.forEach { changed -> + val registered = mutableMapOf() + val added = mutableListOf>() + val coordinator = + GeofenceRegistrationCoordinator( + readRegistered = { registered.toMap() }, + writeRegistered = { + registered.clear() + registered.putAll(it) + }, + add = { + added += it + true + }, + remove = { true }, + ) + coordinator.reconcile(listOf(original)) + coordinator.reconcile(listOf(changed)) + assertEquals(listOf(changed), added.last()) + assertEquals(2, added.size) + } + } + private fun target(id: String): GeofenceTarget = GeofenceTarget(requestId = id, lat = 0.0, lng = 0.0, radiusM = 100f, dwellMinutes = 60) } diff --git a/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRecoveryBroadcastTest.kt b/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRecoveryBroadcastTest.kt new file mode 100644 index 000000000..8ab198839 --- /dev/null +++ b/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRecoveryBroadcastTest.kt @@ -0,0 +1,22 @@ +package com.ruleup.verification.data.signal.geofence + +import android.content.Intent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeofenceRecoveryBroadcastTest { + @Test + fun `재부팅과 Google Play 서비스 데이터 초기화는 지오펜스 복구가 필요하다`() { + assertTrue(requiresGeofenceRecovery(Intent.ACTION_BOOT_COMPLETED, null)) + assertTrue(requiresGeofenceRecovery(Intent.ACTION_PACKAGE_DATA_CLEARED, "com.google.android.gms")) + } + + @Test + fun `다른 앱의 데이터 초기화나 다른 방송은 지오펜스를 재등록하지 않는다`() { + assertFalse(requiresGeofenceRecovery(Intent.ACTION_PACKAGE_DATA_CLEARED, "com.example.other")) + assertFalse(requiresGeofenceRecovery(Intent.ACTION_PACKAGE_DATA_CLEARED, null)) + assertFalse(requiresGeofenceRecovery("other.action", "com.google.android.gms")) + assertFalse(requiresGeofenceRecovery(null, null)) + } +} diff --git a/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationRecoveryTest.kt b/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationRecoveryTest.kt new file mode 100644 index 000000000..67218e206 --- /dev/null +++ b/verification/data/src/test/kotlin/com/ruleup/verification/data/signal/geofence/GeofenceRegistrationRecoveryTest.kt @@ -0,0 +1,33 @@ +package com.ruleup.verification.data.signal.geofence + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeofenceRegistrationRecoveryTest { + @Test + fun `앱이 업데이트된 뒤에는 등록 정보를 다시 확인한다`() { + assertTrue(needsRegistrationRecovery(100, 101, null, true)) + } + + @Test + fun `마지막 등록 이후 사용자가 앱을 종료했으면 등록 정보를 다시 확인한다`() { + assertTrue(needsRegistrationRecovery(100, 90, 101, true)) + } + + @Test + fun `지오펜스 수신 요청이 사라졌으면 등록 정보를 다시 확인한다`() { + assertTrue(needsRegistrationRecovery(100, 90, null, false)) + } + + @Test + fun `과거에 이미 복구한 종료나 일반 프로세스 재시작은 재등록하지 않는다`() { + assertFalse(needsRegistrationRecovery(100, 90, 99, true)) + assertFalse(needsRegistrationRecovery(100, 90, null, true)) + } + + @Test + fun `등록 이력이 없으면 기존 등록을 무효화할 필요가 없다`() { + assertFalse(needsRegistrationRecovery(null, 101, 101, false)) + } +} diff --git a/verification/domain/src/main/kotlin/com/ruleup/verification/domain/repository/GeofenceRegister.kt b/verification/domain/src/main/kotlin/com/ruleup/verification/domain/repository/GeofenceRegister.kt index af073ac65..29e9443c8 100644 --- a/verification/domain/src/main/kotlin/com/ruleup/verification/domain/repository/GeofenceRegister.kt +++ b/verification/domain/src/main/kotlin/com/ruleup/verification/domain/repository/GeofenceRegister.kt @@ -8,7 +8,7 @@ interface GeofenceRegister { suspend fun reconcile(targets: List) /** 로컬에 보존된 목표 전체를 [reconcile] 로 재등록한다. */ - suspend fun reconcilePersisted() + suspend fun reconcilePersisted(forceReregister: Boolean = false) /** 한 멤버([requestIdPrefix] 로 시작하는 requestId 묶음)의 목표 전체를 등록/갱신한다. */ suspend fun bind( diff --git a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt index 9a1a326ed..279421630 100644 --- a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt +++ b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt @@ -57,7 +57,7 @@ class BindLocationUseCaseTest { override suspend fun reconcile(targets: List) = Unit - override suspend fun reconcilePersisted() = Unit + override suspend fun reconcilePersisted(forceReregister: Boolean) = Unit override suspend fun bind( requestIdPrefix: String, diff --git a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/RestoreVerificationTargetsUseCaseTest.kt b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/RestoreVerificationTargetsUseCaseTest.kt index 3979edb6d..f88d7a2e1 100644 --- a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/RestoreVerificationTargetsUseCaseTest.kt +++ b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/RestoreVerificationTargetsUseCaseTest.kt @@ -231,7 +231,7 @@ class RestoreVerificationTargetsUseCaseTest { private class Fences : GeofenceRegister { override suspend fun reconcile(targets: List) = Unit - override suspend fun reconcilePersisted() = Unit + override suspend fun reconcilePersisted(forceReregister: Boolean) = Unit override suspend fun bind( requestIdPrefix: String, diff --git a/verification/presentation/src/test/kotlin/com/ruleup/verification/presentation/location/viewmodel/VerificationLocationViewModelTest.kt b/verification/presentation/src/test/kotlin/com/ruleup/verification/presentation/location/viewmodel/VerificationLocationViewModelTest.kt index 8df01d568..d1eef9382 100644 --- a/verification/presentation/src/test/kotlin/com/ruleup/verification/presentation/location/viewmodel/VerificationLocationViewModelTest.kt +++ b/verification/presentation/src/test/kotlin/com/ruleup/verification/presentation/location/viewmodel/VerificationLocationViewModelTest.kt @@ -130,7 +130,7 @@ class VerificationLocationViewModelTest { private object NoGeofenceRegister : GeofenceRegister { override suspend fun reconcile(targets: List) = throw NotImplementedError() - override suspend fun reconcilePersisted() = throw NotImplementedError() + override suspend fun reconcilePersisted(forceReregister: Boolean) = throw NotImplementedError() override suspend fun bind( requestIdPrefix: String,