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
8 changes: 8 additions & 0 deletions Alarmify/API/AlarmifyAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ protocol AlarmifyAPIClient: Sendable {
func reportAlarmApply(_ report: AlarmApplyReport) async throws
/// 呼び出し元自身のアカウントとサーバー上のデータ (API トークン・配送先・アラーム履歴) を削除する
func deleteAccount() async throws
/// 匿名アカウントの端末を呼び出し元 (Sign in with Apple のアカウント) へ移し、匿名アカウントを削除する。
/// `anonymousIDToken` は統合元の匿名アカウントの Firebase ID トークン
func mergeAnonymousAccount(anonymousIDToken: String) async throws
}

/// URLSession で Cloud Functions のアプリ向け API (`appApi`) を叩く実装。
Expand Down Expand Up @@ -119,6 +122,11 @@ struct URLSessionAlarmifyAPIClient: AlarmifyAPIClient {
_ = try decode(CallableResponse<DeleteAccountResult>.self, from: data).result
}

/// 応答の本文 (移した端末の数) は画面に出さないため読まない
func mergeAnonymousAccount(anonymousIDToken: String) async throws {
_ = try await send(method: "POST", path: "/v1/account/merge", body: ["anonymous_id_token": anonymousIDToken])
}

/// Callable 関数の成功応答
private struct CallableResponse<Result: Decodable>: Decodable {
let result: Result
Expand Down
13 changes: 13 additions & 0 deletions Alarmify/API/AlarmifyAPIError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ enum AlarmifyAPIError: Error, Equatable, LocalizedError {
return false
}

/// 匿名アカウントの統合 (`POST /v1/account/merge`) をサーバーが受け付けない応答のコード。
/// 統合元の ID トークンが期限切れ・匿名でない (`invalid_anonymous_id_token`)、統合先が匿名 (`merge_target_anonymous`)、
/// 統合先が削除処理中 (`account_deleted`)、内容がスキーマに合わない (`invalid_argument`)
static let anonymousAccountMergeRejectedCodes: Set<String> = ["invalid_anonymous_id_token", "merge_target_anonymous", "account_deleted", "invalid_argument"]

/// 匿名アカウントの統合を送り直しても受け付けられない応答かどうか。送り直しを止めてよい判定に使う
var rejectsAnonymousAccountMerge: Bool {
if case .server(_, let code?, _) = self {
return Self.anonymousAccountMergeRejectedCodes.contains(code)
}
return false
}

var errorDescription: String? {
switch self {
case .notSignedIn:
Expand Down
3 changes: 3 additions & 0 deletions Alarmify/API/StubAlarmifyAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,7 @@ actor StubAlarmifyAPIClient: AlarmifyAPIClient {
registeredFCMRegistrationToken = nil
reports.removeAll()
}

/// スタブは Firebase Auth の実アカウントを切り替えないため、統合するデータも無い
func mergeAnonymousAccount(anonymousIDToken: String) async throws {}
}
329 changes: 326 additions & 3 deletions Alarmify/Account/AccountSession.swift

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Alarmify/Alarmify.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<!-- App Check は App Attest の sandbox 環境が発行したトークンを受け付けないため production にする。
TestFlight / App Store 配布ではこの値に関わらず production が使われる -->
<key>com.apple.developer.devicecheck.appattest-environment</key>
Expand Down
19 changes: 19 additions & 0 deletions Alarmify/Features/Purchase/ProEntitlement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ extension String {
/// 購読はアプリ停止中に失効し得るため、proEntitlementActive と対で保存して参照時に同期判定する。
/// 請求猶予期間中はその終了日時 (RevenueCat の SubscriptionInfo.gracePeriodExpiresDate) を保存する
static let proEntitlementExpiration = "proEntitlementExpiration"
/// 既存の Apple アカウントへ切り替えた後、購入を RevenueCat へ送り直す必要がある App User ID (切り替え先の uid)。
/// 送り直せるまで残し、アプリが終了しても次の起動でやり直せるよう UserDefaults に保存する (`AccountSession.syncPendingPurchases`)
static let pendingPurchaseSyncAppUserID = "pendingPurchaseSyncAppUserID"
}

/// キャッシュへ保存する実効的な失効日時。
Expand Down Expand Up @@ -109,6 +112,22 @@ enum ProEntitlement {
Purchases.isConfigured && Purchases.shared.appUserID == appUserID
}

/// この端末の StoreKit の購入を、今の App User ID で RevenueCat へ送り直す。
/// 別の App User ID に結び付いている購入は、プロジェクトの restore behavior に従って今の App User ID へ移る
/// (既定の Transfer to new App User ID の場合。 https://www.revenuecat.com/docs/projects/restore-behavior )。
/// OS のサインインを求めない (restorePurchases と違い Apple ID の入力を促さない)。何度呼んでも同じ状態になる。
/// 送り直せたかを返す (未 configure では送れないため false)
static func syncPurchases() async -> Bool {
guard Purchases.isConfigured else { return false }
do {
cacheEntitlement(customerInfo: try await Purchases.shared.syncPurchases())
return true
} catch {
Logger.purchase.error("RevenueCat syncPurchases failed: \(error.localizedDescription)")
return false
}
}

/// RevenueCat の identity を匿名 ID に戻す。既に匿名なら何もしない (冪等。匿名の logOut は SDK がエラーにする)
static func logOut() async {
guard Purchases.isConfigured, !Purchases.shared.isAnonymous else { return }
Expand Down
60 changes: 60 additions & 0 deletions Alarmify/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -3980,6 +3980,16 @@
}
}
},
"Apple Account" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple アカウント"
}
}
}
},
"Apply" : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -6852,6 +6862,16 @@
}
}
},
"Couldn't complete Sign in with Apple" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple でのサインインを完了できませんでした"
}
}
}
},
"Couldn't link purchases to your account. Check your connection and try again." : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -12188,6 +12208,16 @@
}
}
},
"Moving this iPhone to your Apple account isn't finished yet. It retries when you reopen the app." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "この iPhone の端末情報の移行が完了していません。アプリを開き直すと再試行します"
}
}
}
},
"New token" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -21134,6 +21164,16 @@
}
}
},
"Sign in to use the same API token on multiple iPhones or to move to a new iPhone" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "複数の iPhone で同じ API トークンを使う時や機種変更で引き継ぐ時にサインインします"
}
}
}
},
"Signalarm" : {
"shouldTranslate" : false
},
Expand All @@ -21157,6 +21197,16 @@
}
}
},
"Signed in" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "サインイン済み"
}
}
}
},
"Signing in" : {
"localizations" : {
"ar" : {
Expand Down Expand Up @@ -24283,6 +24333,16 @@
}
}
},
"Try again after the current operation finishes" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "処理中の操作が終わってからやり直してください"
}
}
}
},
"Try issuing again" : {
"localizations" : {
"ja" : {
Expand Down
63 changes: 62 additions & 1 deletion Alarmify/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AlarmKit
import AuthenticationServices
import LicenseList
import SwiftUI
import UserNotifications
Expand Down Expand Up @@ -31,6 +32,8 @@ struct SettingsView: View {
/// バックグラウンドから戻った時に now と権限の状態を取り直すための scene の状態
@Environment(\.scenePhase) private var scenePhase
@Environment(\.openURL) private var openURL
/// Sign in with Apple のボタンの配色を背景と逆にするために読む (Apple の Human Interface Guidelines の推奨)
@Environment(\.colorScheme) private var colorScheme
/// 削除の確認ダイアログの表示状態
@State private var deletionConfirmation = false
@State private var alarmAuthorization = AlarmKitScheduler.authorizationState
Expand Down Expand Up @@ -92,7 +95,7 @@ struct SettingsView: View {
.padding(.horizontal, DesignMetrics.screenHorizontalPadding)
.padding(.top, 14)
.accessibilityIdentifier("settings_delete_account")
.disabled(session.uid == nil || deletionState == .deleting)
.disabled(session.uid == nil || deletionState == .deleting || session.appleSignInInProgress)

if case .failed(let message) = deletionState {
Text(message)
Expand Down Expand Up @@ -299,6 +302,8 @@ struct SettingsView: View {
.rowPadding()
.accessibilityIdentifier("settings_account_id")
HairlineDivider()
appleAccountRow
HairlineDivider()
Link(destination: LegalLinks.supportMail(accountID: session.uid)) {
HStack {
// ja: サポート
Expand All @@ -320,6 +325,59 @@ struct SettingsView: View {
.padding(.horizontal, DesignMetrics.screenHorizontalPadding)
}

/// Sign in with Apple の導線。サインインしなくても従来どおり使え、複数の iPhone で同じアカウントを使う時・機種変更で引き継ぐ時にだけサインインする
@ViewBuilder
private var appleAccountRow: some View {
if session.appleIDLinked {
HStack {
// ja: Apple アカウント
Text("Apple Account")
.font(.body)
.foregroundStyle(Color.paper)
Spacer()
// ja: サインイン済み
Text("Signed in")
.font(.body)
.foregroundStyle(Color.paperTertiary)
}
.rowPadding()
.accessibilityIdentifier("settings_apple_account")
} else {
VStack(alignment: .leading, spacing: 10) {
SignInWithAppleButton(.signIn) { request in
session.prepare(appleIDRequest: request)
} onCompletion: { result in
Task { await session.completeSignInWithApple(result: result) }
}
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
// SignInWithAppleButton は表示した後にスタイルが変わっても描き直さない (simtunnel で外観を切り替えて確認) ため、外観ごとに作り直す
.id(colorScheme)
.frame(height: 44)
.disabled(session.uid == nil || session.appleSignInInProgress || session.accountDeletionInProgress)
.accessibilityIdentifier("settings_sign_in_with_apple")
// ja: 複数の iPhone で同じ API トークンを使う時や機種変更で引き継ぐ時にサインインします
Text("Sign in to use the same API token on multiple iPhones or to move to a new iPhone")
.font(.footnote)
.foregroundStyle(Color.paperTertiary)
}
.rowPadding()
}
if session.appleSignInInProgress {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.bottom, 12)
}
if let appleSignInError = session.appleSignInError {
Text(appleSignInError)
.font(.footnote)
.foregroundStyle(Color.destructive)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, DesignMetrics.textHorizontalPadding)
.padding(.bottom, 12)
.accessibilityIdentifier("settings_apple_sign_in_error")
}
}

private var legalCard: some View {
VStack(spacing: 0) {
// ja: 利用規約
Expand Down Expand Up @@ -473,6 +531,9 @@ struct SettingsView: View {
do {
try await session.deleteAccount()
deletionState = .deleted
} catch let error as ASAuthorizationError where error.code == .canceled {
// Apple のトークンの失効に必要なサインインのシートを閉じた。削除をやめたものとして扱う
deletionState = .idle
} catch {
deletionState = .failed(message: error.localizedDescription)
}
Expand Down
3 changes: 3 additions & 0 deletions Alarmify/Shared/Log.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ extension Logger {
/// App Check のトークン取得に関するログ。取得できなくてもリクエストは送るため、失敗はここにだけ残る
static let appCheck = Logger(subsystem: subsystem, category: "appCheck")

/// Sign in with Apple のリンク・アカウントの統合・トークンの失効に関するログ
static let account = Logger(subsystem: subsystem, category: "account")

/// RevenueCat の identity 連携 (logIn) と entitlement の反映に関するログ
static let purchase = Logger(subsystem: subsystem, category: "purchase")
}
13 changes: 13 additions & 0 deletions AlarmifyTests/AccountDeletionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ final class AccountDeletionTests: XCTestCase {
XCTAssertTrue(tokens.isEmpty)
}

/// 未完了の匿名アカウントの統合は、アプリが終了しても次の起動で送り直せるよう keychain に残り、nil で消える
func testPendingAnonymousMergeIDTokenSurvivesUntilCleared() {
PendingAnonymousMergeIDTokenStore.save(nil)
XCTAssertNil(PendingAnonymousMergeIDTokenStore.load())

PendingAnonymousMergeIDTokenStore.save("anonymous-id-token-1")
PendingAnonymousMergeIDTokenStore.save("anonymous-id-token-2")
XCTAssertEqual(PendingAnonymousMergeIDTokenStore.load(), "anonymous-id-token-2")

PendingAnonymousMergeIDTokenStore.save(nil)
XCTAssertNil(PendingAnonymousMergeIDTokenStore.load())
}

/// 削除手順のページは ja 版と en 版しか公開していないため、日本語以外の表示言語では英語版へ寄せる
/// (Localizable.xcstrings で言語別に URL を持つと、翻訳した言語ぶんの存在しないページへのリンクになる。PR #43 の Codex 指摘)
func testAccountDeletionGuideFallsBackToEnglishForUnsupportedLanguages() {
Expand Down
24 changes: 24 additions & 0 deletions AlarmifyTests/AlarmifyAPIClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ final class AlarmifyAPIClientTests: XCTestCase {
try await makeClient().registerDevice(fcmRegistrationToken: "fcm-token")
}

/// 統合先は Authorization の ID トークン (Apple 側)、統合元は本文の匿名アカウントの ID トークンで伝える
func testMergeAnonymousAccountSendsTheAnonymousIDTokenInTheBody() async throws {
StubURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path(), "/demo-alarmify/asia-northeast1/appApi/v1/account/merge")
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer id-token")
let body = (try? JSONSerialization.jsonObject(with: StubURLProtocol.body(of: request))) as? [String: String]
XCTAssertEqual(body, ["anonymous_id_token": "anonymous-id-token"])
return (200, Data(#"{"moved_devices":1}"#.utf8))
}

try await makeClient().mergeAnonymousAccount(anonymousIDToken: "anonymous-id-token")
}

/// 送り直しても通らない統合の拒否だけを止め、通信エラー等は次の起動で送り直せるよう区別する
func testRejectedAnonymousAccountMergeIsRecognizedFromTheErrorCode() {
for code in ["invalid_anonymous_id_token", "merge_target_anonymous", "account_deleted", "invalid_argument"] {
XCTAssertTrue(AlarmifyAPIError.server(statusCode: 400, code: code, message: "").rejectsAnonymousAccountMerge, code)
}
XCTAssertFalse(AlarmifyAPIError.server(statusCode: 404, code: "not_found", message: "").rejectsAnonymousAccountMerge)
XCTAssertFalse(AlarmifyAPIError.server(statusCode: 503, code: nil, message: "").rejectsAnonymousAccountMerge)
XCTAssertFalse(AlarmifyAPIError.notSignedIn.rejectsAnonymousAccountMerge)
}

func testRevokeUsesTheTokenIdInThePath() async throws {
StubURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "DELETE")
Expand Down
Loading
Loading