Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`를
Expand Down
7 changes: 6 additions & 1 deletion verification/data/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<!-- Health Connect 패키지 조회. -->
<queries>
<package android:name="com.google.android.apps.healthdata" />
<package android:name="com.google.android.gms" />
</queries>

<application>
Expand All @@ -40,13 +41,17 @@
android:name="com.ruleup.verification.data.signal.geofence.GeofenceBroadcastReceiver"
android:exported="false" />

<!-- 시스템 부팅 방송 수신 및 지오펜스 재등록. -->
<!-- 부팅·Google Play 서비스 초기화 시 지오펜스 복원. -->
<receiver
android:name="com.ruleup.verification.data.signal.geofence.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PACKAGE_DATA_CLEARED" />
<data android:scheme="package" android:ssp="com.google.android.gms" />
</intent-filter>
</receiver>
</application>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -47,6 +49,19 @@ class VerificationSettingsStore
dataStore.edit { it[KEY_LAST_COVERED_UNTIL] = at }
}

suspend fun registeredGeofences(): Map<String, String> =
dataStore.data.first()[KEY_REGISTERED_GEOFENCES]?.let { raw ->
runCatching { Json.decodeFromString<Map<String, String>>(raw) }.getOrNull()
} ?: emptyMap()

suspend fun setRegisteredGeofences(registered: Map<String, String>) {
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) {
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject

/** 재부팅 후 지오펜스 재등록. */
/** OS 등록 유실 시 지오펜스 복원. */
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject
Expand All @@ -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 {
Expand All @@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -29,72 +34,81 @@ class GeofenceRegisterImpl
) : GeofenceRegister {
private val client by lazy { LocationServices.getGeofencingClient(context.applicationContext) }

override suspend fun reconcile(targets: List<GeofenceTarget>) {
// 권한 없으면 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<GeofenceTarget>) = 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<GeofenceTarget>,
) {
// 이 멤버(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<GeofenceTarget>) {
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<GeofenceTarget>) {
private suspend fun registerAll(targets: List<GeofenceTarget>): 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()
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>,
private val writeRegistered: suspend (Map<String, String>) -> Unit,
private val add: suspend (List<GeofenceTarget>) -> Boolean,
private val remove: suspend (List<String>) -> Boolean,
) {
suspend fun reconcile(
targets: List<GeofenceTarget>,
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) }
}
Loading
Loading