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
157 changes: 152 additions & 5 deletions UnstoppableWallet/UnstoppableWallet/Core/Managers/TermsManager.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String>

init(version: Int, acceptedTermIds: Set<String>) {
self.version = version
self.acceptedTermIds = acceptedTermIds
}

var allAccepted: Bool {
guard !TermsConfiguration.current.terms.isEmpty else {
return false
}

return TermsConfiguration.current.all.isSubset(of: acceptedTermIds)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () },
Expand Down Expand Up @@ -125,7 +125,7 @@ class MainSettingsViewModel: ObservableObject {
}

private func syncAboutAlert() {
aboutAlert = !termsManager.termsAccepted
aboutAlert = !termsManager.state.allAccepted
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>()
@State private var checkedIds = Set<String>()

init(isPresented: Binding<Bool>, onAccept: (() -> Void)? = nil) {
_isPresented = isPresented
Expand All @@ -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()
Expand All @@ -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)
}
}
}
Expand All @@ -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))
}
}
}
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,38 @@ import Combine

class TermsViewModel: ObservableObject {
private let termsManager = Core.shared.termsManager
private var cancellables = Set<AnyCancellable>()

let termsAccepted: Bool
@Published var acceptedTermIds: Set<String> = []
@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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down