diff --git a/.github/scripts/backup-policy.test.mjs b/.github/scripts/backup-policy.test.mjs new file mode 100644 index 00000000..9396e82d --- /dev/null +++ b/.github/scripts/backup-policy.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); + +test('backup and device transfer exclude device bindings and keep legacy restore cleanup enabled', () => { + const result = spawnSync('python3', ['-c', ` +from pathlib import Path +import sys +import xml.etree.ElementTree as ET +root = Path(sys.argv[1]) +expected = {'app_state.xml', 'settings.xml', 'scheduled_alarms.xml', 'snoozed_alarms.xml'} +for name in ('backup_rules', 'data_extraction_rules'): + tree = ET.parse(root / f'app/src/main/res/xml/{name}.xml').getroot() + groups = [tree] if name == 'backup_rules' else list(tree) + if name == 'data_extraction_rules': + assert {group.tag for group in groups} >= {'cloud-backup', 'device-transfer'} + for group in groups: + excluded = {item.get('path') for item in group.findall('exclude') if item.get('domain') == 'sharedpref'} + assert expected <= excluded, (name, group.tag, expected - excluded) +app = ET.parse(root / 'app/src/main/AndroidManifest.xml').getroot().find('application') +ns = '{http://schemas.android.com/apk/res/android}' +assert app.get(ns + 'backupAgent') == '.backup.SlowClockBackupAgent' +assert app.get(ns + 'fullBackupOnly') == 'true' +`, root], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/app/src/androidTest/java/com/example/slowclock/backup/SlowClockBackupAgentTest.kt b/app/src/androidTest/java/com/example/slowclock/backup/SlowClockBackupAgentTest.kt new file mode 100644 index 00000000..983178cb --- /dev/null +++ b/app/src/androidTest/java/com/example/slowclock/backup/SlowClockBackupAgentTest.kt @@ -0,0 +1,68 @@ +package com.example.slowclock.backup + +import android.content.Context +import android.content.ContextWrapper +import android.content.SharedPreferences +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SlowClockBackupAgentTest { + @Test + fun restoredDevicePreferencesAreClearedBeforeTheNextLaunch() { + // 테스트 전용 파일명으로 격리해 앱의 로그인과 설정을 건드리지 않는다. + val context = + object : ContextWrapper(InstrumentationRegistry.getInstrumentation().targetContext) { + override fun getSharedPreferences( + name: String, + mode: Int, + ): SharedPreferences = super.getSharedPreferences("backup_test_201_$name", mode) + } + val names = listOf("app_state", "settings", "scheduled_alarms", "snoozed_alarms") + val unrelated = context.getSharedPreferences("backup_test_unrelated", Context.MODE_PRIVATE) + try { + names.forEach { name -> + assertTrue( + context + .getSharedPreferences(name, Context.MODE_PRIVATE) + .edit() + .putString("restored", "old-install") + .commit(), + ) + } + val appState = context.getSharedPreferences("app_state", Context.MODE_PRIVATE) + assertTrue(appState.edit().putBoolean("app_launched", true).commit()) + val settings = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + assertTrue(settings.edit().putString("share_code", "ABC123").commit()) + assertTrue(unrelated.edit().putString("preserved", "value").commit()) + + val agent = SlowClockBackupAgent() + // 시스템이 붙이는 Context를 파일명이 격리된 테스트 Context로 대신한다. + ContextWrapper::class.java.getDeclaredMethod("attachBaseContext", Context::class.java).apply { + isAccessible = true + invoke(agent, context) + } + agent.onRestoreFinished() + + names.forEach { name -> assertTrue(context.getSharedPreferences(name, Context.MODE_PRIVATE).all.isEmpty()) } + assertFalse(appState.getBoolean("app_launched", false)) + assertFalse(settings.contains("share_code")) + assertEquals("value", unrelated.getString("preserved", null)) + agent.onRestoreFinished() + assertFalse(appState.getBoolean("app_launched", false)) + } finally { + (names + "backup_test_unrelated").forEach { name -> + context + .getSharedPreferences(name, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7cf304a4..363cab3b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,8 @@ + + - - \ No newline at end of file + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index c9974e6f..d3ad95fc 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,10 +5,15 @@ 있다. 새 기기로 복원되면 그 기기가 남의 일정으로 알람을 울린다. 클라우드 백업과 기기 이전 양쪽에서 뺀다(#127 · #177). + 설치 표시(app_state)와 공유 코드가 담긴 settings도 기기에 종속되므로 제외한다(#201). + 이전 백업에 포함된 값은 SlowClockBackupAgent가 복원 완료 때 비운다. + 새 기기에서는 다시 로그인하고 공유 코드를 등록하며 테마도 기본값으로 시작한다. 장부가 빈 채로 시작하는 새 기기에서는 로그인 뒤 서버의 일정 목록으로 알람을 다시 맞춘다(#176). --> + + @@ -17,6 +22,8 @@ path="snoozed_alarms.xml" /> + + diff --git a/docs/testing/backup.md b/docs/testing/backup.md new file mode 100644 index 00000000..1999a33b --- /dev/null +++ b/docs/testing/backup.md @@ -0,0 +1,18 @@ +# 기기 백업·복원 검증 + +설치 표시, 공유 코드가 포함된 설정, 예약·미룸 알람 장부는 클라우드 백업과 기기 이전에서 제외한다. 새 기기에서 로그인과 공유 코드 등록을 다시 거치며 테마도 기본값으로 시작한다. 서버의 일정은 삭제하지 않는다. 일반 앱 업데이트는 현재 기기의 설정을 유지한다. + +이전 버전 백업에 해당 파일이 들어 있을 수 있어 `SlowClockBackupAgent.onRestoreFinished()`에서 같은 네 preferences를 동기적으로 비운다. 이 콜백은 앱의 Hilt Application/Firebase provider를 사용하지 않는다. 복원 뒤 앱을 처음 열면 기존 새 설치 처리에서 인증 세션을 정리한다. + +## 자동 검사 + +- `node --test .github/scripts/backup-policy.test.mjs`: 두 백업 형식의 제외 목록과 manifest의 복원 agent·Auto Backup 모드 연결. +- `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.slowclock.backup.SlowClockBackupAgentTest`: Android에서 실제 복원 완료 콜백을 실행한다. 이전 설치 값 제거, 다른 preferences 보존, 재실행 안전성을 확인한다. 테스트 preferences에는 별도 접두사를 붙여 기존 로그인·설정을 보존한다. + +## 기기 이전 확인 + +이전 버전 백업을 새 버전 앱으로 복원한 뒤, 첫 실행 전 기기별 preferences가 비어 있는지 확인한다. 첫 실행에는 새 로그인을 요구하고 공유 목록은 코드 재등록 전 표시되지 않아야 한다. 원래 기기에서 앱만 업데이트하면 기존 로그인·공유 설정이 유지되어야 한다. + +2026-09-06 API 34 전용 에뮬레이터에서 콜백 계측 검사를 통과했다. Google 클라우드 백업·제조사 기기 이전 전체 왕복은 별도 기기 검증 대상이다. + +근거: [Android Auto Backup의 사용자 정의 agent](https://developer.android.com/identity/data/autobackup#ImplementingBackupAgent), [복원 완료 콜백](https://developer.android.com/reference/android/app/backup/BackupAgent#onRestoreFinished()).