From 4d6a678e66b88b45f18a1654a428e4ff0e556a05 Mon Sep 17 00:00:00 2001 From: Anton Stavnichiy Date: Sun, 9 Nov 2025 12:59:55 +0700 Subject: [PATCH] Add versioned terms acceptance system - migrate from boolean flag to term-by-term tracking with version support. - old users (2 accepted terms) automatically migrated to v2 (5 total terms). --- .../Core/Managers/TermsManager.swift | 157 +++++++++++++++++- .../Modules/Coordinator/Coordinator.swift | 2 +- .../Modules/Main/MainBadgeViewModel.swift | 4 +- .../Settings/About/AboutViewModel.swift | 4 +- .../Settings/Main/MainSettingsViewModel.swift | 4 +- .../Modules/Settings/Terms/TermsView.swift | 44 ++--- .../Settings/Terms/TermsViewModel.swift | 27 ++- .../en.lproj/Localizable.strings | 8 +- 8 files changed, 215 insertions(+), 35 deletions(-) diff --git a/UnstoppableWallet/UnstoppableWallet/Core/Managers/TermsManager.swift b/UnstoppableWallet/UnstoppableWallet/Core/Managers/TermsManager.swift index dad4aa517f..e7b818f9cb 100644 --- a/UnstoppableWallet/UnstoppableWallet/Core/Managers/TermsManager.swift +++ b/UnstoppableWallet/UnstoppableWallet/Core/Managers/TermsManager.swift @@ -1,22 +1,169 @@ import Combine +import Foundation import HsExtensions class TermsManager { - private let keyTermsAccepted = "key_terms_accepted" + private static let keyTermsAccepted = "key_terms_accepted" + private static let keyTermsState = "key_terms_state" private let userDefaultsStorage: UserDefaultsStorage - @DistinctPublished var termsAccepted: Bool + @DistinctPublished var state: TermsState init(userDefaultsStorage: UserDefaultsStorage) { self.userDefaultsStorage = userDefaultsStorage - termsAccepted = userDefaultsStorage.value(for: keyTermsAccepted) ?? false + state = TermsManager.migrateIfNeeded(storage: userDefaultsStorage) } } extension TermsManager { func setTermsAccepted() { - userDefaultsStorage.set(value: true, for: keyTermsAccepted) - termsAccepted = true + saveState(TermsState.accepted) + } + + private func saveState(_ state: TermsState) { + if let data = try? JSONEncoder().encode(state) { + userDefaultsStorage.set(value: data, for: Self.keyTermsState) + self.state = state + } + } + + static func migrateIfNeeded(storage: UserDefaultsStorage) -> TermsState { + // 1. Try to load current state + if let data: Data = storage.value(for: keyTermsState), + let state = try? JSONDecoder().decode(TermsState.self, from: data) + { + let currentVersion = TermsConfiguration.configurations.count + + // Already on current version + if state.version == currentVersion { + return state + } + + // Migrate to current version + let migratedState = migrate(from: state, to: currentVersion) + + // Save migrated state + if let data = try? JSONEncoder().encode(migratedState) { + storage.set(value: data, for: keyTermsState) + } + + return migratedState + } + + // 2. Legacy migration: check old boolean flag + if let legacyAccepted: Bool = storage.value(for: keyTermsAccepted), + legacyAccepted + { + // User accepted v1 terms, migrate to current version + let v1State = TermsState( + version: 1, + acceptedTermIds: TermsConfiguration.configurations.first?.all ?? [] + ) + + let currentVersion = TermsConfiguration.configurations.count + let migratedState = migrate(from: v1State, to: currentVersion) + + // Save migrated state + if let data = try? JSONEncoder().encode(migratedState) { + storage.set(value: data, for: keyTermsState) + } + + // Clean up legacy key + storage.set(value: nil as Bool?, for: keyTermsAccepted) + + return migratedState + } + + // 3. New user - no terms accepted + return TermsState.notAccepted + } + + private static func migrate(from oldState: TermsState, to newVersion: Int) -> TermsState { + guard let newConfiguration = TermsConfiguration.configurations.last else { + return oldState + } + + // Keep only terms that still exist in current configuration + let currentTermIds = newConfiguration.all + let validAcceptedIds = oldState.acceptedTermIds.intersection(currentTermIds) + + return TermsState( + version: newVersion, + acceptedTermIds: validAcceptedIds + ) + } +} + +extension TermsManager { + struct Term: Identifiable, Codable, Hashable { + let id: String + let version: Int + + var localizedKey: String { + "terms.item.\(id)" + } + + init(id: String, version: Int = 1) { + self.id = id + self.version = version + } + } + + struct TermsConfiguration { + let version: Int + let terms: [Term] + + static let configurations = [ + TermsConfiguration( + version: 1, + terms: [ + Term(id: "backup_recovery", version: 1), + Term(id: "device_pin", version: 1), + ] + ), + TermsConfiguration( + version: 2, + terms: [ + Term(id: "backup_recovery", version: 1), + Term(id: "device_pin", version: 1), + Term(id: "privacy_notice", version: 1), + Term(id: "monetization", version: 1), + Term(id: "open_source", version: 1), + ] + ), + ] + + var all: Set { + Set(terms.map(\.id)) + } + + static var current: TermsConfiguration { + configurations.last ?? .init(version: 0, terms: []) + } + } + + struct TermsState: Codable, Equatable { + static let notAccepted = TermsState(version: TermsConfiguration.configurations.count, acceptedTermIds: []) + static let accepted = TermsState( + version: TermsConfiguration.configurations.count, + acceptedTermIds: TermsConfiguration.current.all + ) + + let version: Int + let acceptedTermIds: Set + + init(version: Int, acceptedTermIds: Set) { + self.version = version + self.acceptedTermIds = acceptedTermIds + } + + var allAccepted: Bool { + guard !TermsConfiguration.current.terms.isEmpty else { + return false + } + + return TermsConfiguration.current.all.isSubset(of: acceptedTermIds) + } } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Coordinator/Coordinator.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Coordinator/Coordinator.swift index 333afeba12..b4c7baa4fc 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Coordinator/Coordinator.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Coordinator/Coordinator.swift @@ -113,7 +113,7 @@ extension Coordinator { onPresent?() } - if Core.shared.termsManager.termsAccepted { + if Core.shared.termsManager.state.allAccepted { onAccept() } else { Coordinator.shared.present { isPresented in diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Main/MainBadgeViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Main/MainBadgeViewModel.swift index dc7d7de07a..74e7b1017e 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Main/MainBadgeViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Main/MainBadgeViewModel.swift @@ -32,7 +32,7 @@ class MainBadgeViewModel: ObservableObject { .sink { [weak self] _ in self?.syncSettingsBadge() } .store(in: &cancellables) - termsManager.$termsAccepted + termsManager.$state .sink { [weak self] _ in self?.syncSettingsBadge() } .store(in: &cancellables) @@ -63,7 +63,7 @@ class MainBadgeViewModel: ObservableObject { } let cloudError = contactManager.iCloudError != nil && contactManager.remoteSync - let visible = accountRestoreWarningManager.hasNonStandard || !backupManager.allBackedUp || !passcodeManager.isPasscodeSet || !termsManager.termsAccepted || cloudError + let visible = accountRestoreWarningManager.hasNonStandard || !backupManager.allBackedUp || !passcodeManager.isPasscodeSet || !termsManager.state.allAccepted || cloudError return visible ? "" : nil } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/About/AboutViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/About/AboutViewModel.swift index 702baa503a..71aba39225 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/About/AboutViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/About/AboutViewModel.swift @@ -14,9 +14,9 @@ class AboutViewModel: ObservableObject { self.systemInfoManager = systemInfoManager self.releaseNotesService = releaseNotesService - termsManager.$termsAccepted.sink { [weak self] in self?.syncTermsAlert(termsAccepted: $0) }.store(in: &cancellables) + termsManager.$state.sink { [weak self] in self?.syncTermsAlert(termsAccepted: $0.allAccepted) }.store(in: &cancellables) - syncTermsAlert(termsAccepted: termsManager.termsAccepted) + syncTermsAlert(termsAccepted: termsManager.state.allAccepted) } private func syncTermsAlert(termsAccepted: Bool) { diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Main/MainSettingsViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Main/MainSettingsViewModel.swift index eae3e26e09..bf36e10e00 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Main/MainSettingsViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Main/MainSettingsViewModel.swift @@ -71,7 +71,7 @@ class MainSettingsViewModel: ObservableObject { subscribe(&cancellables, accountRestoreWarningManager.hasNonStandardPublisher) { [weak self] _ in self?.syncManageWalletsAlert() } subscribe(&cancellables, passcodeManager.$isPasscodeSet) { [weak self] _ in self?.syncSecurityAlert() } - subscribe(&cancellables, termsManager.$termsAccepted) { [weak self] _ in self?.syncAboutAlert() } + subscribe(&cancellables, termsManager.$state) { [weak self] _ in self?.syncAboutAlert() } Publishers.Merge3( purchaseManager.$purchaseData.map { _ in () }, @@ -125,7 +125,7 @@ class MainSettingsViewModel: ObservableObject { } private func syncAboutAlert() { - aboutAlert = !termsManager.termsAccepted + aboutAlert = !termsManager.state.allAccepted } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsView.swift index 9a6dc88a2b..c8003f7479 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsView.swift @@ -1,14 +1,12 @@ import SwiftUI struct TermsView: View { - private let termCount = 2 - @StateObject private var viewModel = TermsViewModel() @Binding var isPresented: Bool let onAccept: (() -> Void)? - @State private var checkedIndices = Set() + @State private var checkedIds = Set() init(isPresented: Binding, onAccept: (() -> Void)? = nil) { _isPresented = isPresented @@ -18,11 +16,11 @@ struct TermsView: View { var body: some View { ThemeNavigationStack { ThemeView { - if viewModel.termsAccepted { - content() + if viewModel.allTermsAccepted { + content(readOnly: true) } else { BottomGradientWrapper { - content() + content(readOnly: false) } bottomContent: { Button(action: { viewModel.setTermsAccepted() @@ -32,7 +30,7 @@ struct TermsView: View { Text("terms.i_agree".localized) } .buttonStyle(PrimaryButtonStyle(style: .yellow)) - .disabled(checkedIndices.count < termCount) + .disabled(checkedIds.count < viewModel.terms.count) } } } @@ -44,27 +42,26 @@ struct TermsView: View { } } } + .onAppear { + checkedIds = viewModel.acceptedTermIds + } } } - @ViewBuilder private func content() -> some View { + @ViewBuilder private func content(readOnly: Bool) -> some View { ScrollView { VStack(spacing: .margin24) { ListSection { - ForEach(0 ..< termCount, id: \.self) { index in - if viewModel.termsAccepted { + ForEach(viewModel.terms) { term in + if readOnly { ListRow { - rowContent(index: index, checked: true) + rowContent(term: term, checked: true) } } else { ClickableRow(action: { - if checkedIndices.contains(index) { - checkedIndices.remove(index) - } else { - checkedIndices.insert(index) - } + toggleTerm(term) }) { - rowContent(index: index, checked: checkedIndices.contains(index)) + rowContent(term: term, checked: checkedIds.contains(term.id)) } } } @@ -74,8 +71,17 @@ struct TermsView: View { } } - @ViewBuilder private func rowContent(index: Int, checked: Bool) -> some View { + @ViewBuilder private func rowContent(term: TermsManager.Term, checked: Bool) -> some View { Image(checked ? "checkbox_active_24" : "checkbox_diactive_24") - Text("terms.item.\(index + 1)".localized).themeSubhead2(color: .themeLeah) + Text(term.localizedKey.localized) + .themeSubhead2(color: .themeLeah) + } + + private func toggleTerm(_ term: TermsManager.Term) { + if checkedIds.contains(term.id) { + checkedIds.remove(term.id) + } else { + checkedIds.insert(term.id) + } } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsViewModel.swift index 71b1082ac2..34a55d562f 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Settings/Terms/TermsViewModel.swift @@ -2,15 +2,38 @@ import Combine class TermsViewModel: ObservableObject { private let termsManager = Core.shared.termsManager + private var cancellables = Set() - let termsAccepted: Bool + @Published var acceptedTermIds: Set = [] + @Published var allTermsAccepted: Bool = false + + var terms: [TermsManager.Term] { + TermsManager.TermsConfiguration.current.terms + } init() { - termsAccepted = termsManager.termsAccepted + termsManager.$state + .sink { [weak self] state in + self?.sync(state: state) + } + .store(in: &cancellables) + + sync() + } + + private func sync(state: TermsManager.TermsState? = nil) { + let state = state ?? termsManager.state + + acceptedTermIds = state.acceptedTermIds + allTermsAccepted = state.allAccepted } } extension TermsViewModel { + func isTermAccepted(_ term: TermsManager.Term) -> Bool { + acceptedTermIds.contains(term.id) + } + func setTermsAccepted() { termsManager.setTermsAccepted() } diff --git a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings index 11a48d0392..066ac246b3 100644 --- a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings +++ b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings @@ -1506,8 +1506,12 @@ "terms.title" = "Security Terms"; "terms.i_agree" = "I Agree"; -"terms.item.1" = "Securely backup recovery phrases for each wallet. It's the only way to regain access to funds if the app malfunctions."; -"terms.item.2" = "Disabling unlock PIN (code) on the smartphone deletes all wallets from the app. Recovery phrases will be needed to restore access to funds."; +"terms.item.backup_recovery" = "Securely back up the recovery phrase for each wallet. It’s the only way to regain access to your funds if the app is deleted, lost, or malfunctions. No one can restore it for you."; +"terms.item.device_pin" = "Disabling your smartphone’s unlock PIN (or passcode) wipes all wallets from the app. You’ll need your recovery phrases to restore access."; +"terms.item.privacy_notice" = "The app doesn’t spy or collect data about your balances or transactions - we don’t even know how many people use it. It only tracks anonymous usage patterns (pages opened, buttons tapped) to improve usability. You can turn this off in Settings → Privacy."; +"terms.item.monetization" = "As part of its monetization model, the app earns small commissions from DEX swaps executed within the wallet. This helps us stay independent, open source, and free of VC money."; +"terms.item.open_source" = "The app is verifiably open source, with all source code published online. Everything stated above can be publicly verified by anyone."; + // Settings -> Tell Friends