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
29 changes: 29 additions & 0 deletions .github/scripts/backup-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
<application
android:name=".SlowClockApplication"
android:allowBackup="true"
android:backupAgent=".backup.SlowClockBackupAgent"
android:fullBackupOnly="true"
android:usesCleartextTraffic="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.slowclock.backup

import android.app.backup.BackupAgentHelper
import java.io.IOException

/**
* 이전 버전 백업에 남은 기기 설정도 복원 직후 비운다. 다음 실행은 새 설치의 로그아웃·초기화를 거친다.
* 일반 앱 업데이트에서는 이 콜백이 실행되지 않으므로 현재 기기의 설정은 유지된다.
*
* 복원 중에는 Hilt Application과 Firebase provider가 초기화되지 않으므로 사용하지 않는다.
* https://developer.android.com/identity/data/autobackup#ImplementingBackupAgent
*/
class SlowClockBackupAgent : BackupAgentHelper() {
override fun onRestoreFinished() {
super.onRestoreFinished()
for (name in DEVICE_PREFERENCES) {
// 복원 프로세스가 바로 끝나도 다음 앱 실행 전에 정리가 디스크에 반영되어야 한다.
if (!getSharedPreferences(name, MODE_PRIVATE).edit().clear().commit()) {
throw IOException("Restored device preferences could not be cleared: $name")
}
}
}

private companion object {
val DEVICE_PREFERENCES = listOf("app_state", "settings", "scheduled_alarms", "snoozed_alarms")
}
}
19 changes: 7 additions & 12 deletions app/src/main/res/xml/backup_rules.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<?xml version="1.0" encoding="utf-8"?>
<!-- 기기별 설치·공유·알람 상태는 다른 설치에서 다시 확인한다. -->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
<exclude domain="sharedpref" path="app_state.xml" />
<exclude domain="sharedpref" path="settings.xml" />
<exclude domain="sharedpref" path="scheduled_alarms.xml" />
<exclude domain="sharedpref" path="snoozed_alarms.xml" />
</full-backup-content>
7 changes: 7 additions & 0 deletions app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
있다. 새 기기로 복원되면 그 기기가 남의 일정으로 알람을 울린다. 클라우드 백업과 기기 이전
양쪽에서 뺀다(#127 · #177).

설치 표시(app_state)와 공유 코드가 담긴 settings도 기기에 종속되므로 제외한다(#201).
이전 백업에 포함된 값은 SlowClockBackupAgent가 복원 완료 때 비운다.
새 기기에서는 다시 로그인하고 공유 코드를 등록하며 테마도 기본값으로 시작한다.
장부가 빈 채로 시작하는 새 기기에서는 로그인 뒤 서버의 일정 목록으로 알람을 다시 맞춘다(#176).
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="app_state.xml" />
<exclude domain="sharedpref" path="settings.xml" />
<exclude
domain="sharedpref"
path="scheduled_alarms.xml" />
Expand All @@ -17,6 +22,8 @@
path="snoozed_alarms.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="app_state.xml" />
<exclude domain="sharedpref" path="settings.xml" />
<exclude
domain="sharedpref"
path="scheduled_alarms.xml" />
Expand Down
18 changes: 18 additions & 0 deletions docs/testing/backup.md
Original file line number Diff line number Diff line change
@@ -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()).
Loading