Compare commits

..

3 Commits

Author SHA1 Message Date
5476209c50 Analytics: make retention measurable
Aptabase hashes its user_id from IP + user agent with a salt that rotates
daily, so events can never be linked across days and no retention or MAU
figure can come out of the export. In 19 days of data not one of 82 user_ids
appears on two dates, which is the artefact, not the behaviour.

Instead of an identity, every event now carries this install's own counters,
kept in UserDefaults/DataStore: tenure_days, launch_no, active_days and
dormant_days. Only derived day counts leave the device, so the privacy
position is unchanged.

They make the curve countable in the export: launch_no == 1 marks exactly one
launch per install, dormant_days >= 1 exactly one launch per calendar day, so
D_k is the share of installs seen again with tenure_days == k. Event date
minus tenure_days is the install date, which gives full cohort tables.

Installs predating the counters have no install date and report
tenure_days == -1 forever, so they can be excluded instead of inflating the
new-install cohort.

The Kotlin counter arithmetic sits in a pure advance() so it can be tested
without a Context; this adds the app module's first JVM test source set.
2026-08-19 17:43:45 +02:00
61e0cb061f android: target Android 16 (API 36)
Play blocks updates from August 31, 2026 unless the app targets API 36.
Bump compileSdk/targetSdk to 36 in :app and :baselineprofile, and AGP to
8.10.1 -- 8.7.3 cannot build against API 36. Gradle stays at 8.11.1, which
is what AGP 8.10 requires.

Nothing in the app relies on the behaviours Android 16 changed: edge-to-edge
is already opted in via enableEdgeToEdge(), the manifest pins no orientation
or resizability, and the two bundled native libraries (graphics-path,
datastore_shared_counter) are already 16 KB page aligned.

Align the version with iOS (1.8.1, code 88) so the API 36 build can ship.
2026-08-19 15:35:06 +02:00
9673bde107 Review: ask on value milestones, not exports
The old gate needed two exports, three days of install age, one prompt per
version and 120 days between prompts. Aptabase shows only 9% of users ever
export and 5% export twice, so `Review Prompt Requested` never fired once in
19 days and the store has a single rating.

Replace the export counter with three milestones -- a fully configured system,
an opened bill of materials, a completed export -- each counted at most once
per install, and ask once two different ones are reached. Recording and asking
are now separate: milestones are booked mid-task, where StoreKit and Play drop
the request, so the ask happens from calm screens only (system overview,
export preview dismissal, share sheet dismissal).

Two fixes that made the old prompt lose its slot for good: the throttle keys
were written before checking for a foreground scene, and the export trigger
fired while the Quick Look sheet was still animating away. Both platforms now
mark the throttle only once the store API really has a review flow to show.

Add a Settings entry that links straight to the store review page. It is never
throttled, and 45% of users open Settings versus 9% who export. New analytics
event `Review Milestone Reached` makes the funnel measurable.

Existing installs keep their legacy export credit and still need a second
milestone before being asked.
2026-08-19 15:07:46 +02:00
31 changed files with 946 additions and 153 deletions

View File

@@ -19,6 +19,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore")
if isFirstLaunch {
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
}
// Before the first log call: every event carries this launch's tenure counters.
UsageMetrics.beginLaunch(isFirstLaunch: isFirstLaunch)
if isFirstLaunch {
AnalyticsTracker.log("First Launch")
}
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
@@ -31,8 +35,12 @@ enum AnalyticsTracker {
static func configure() {}
static func log(_ event: String, properties: [String: Any] = [:]) {
// Tenure counters first so an explicit property of the same name would win.
var merged = UsageMetrics.eventProps
for (key, value) in properties { merged[key] = value }
var converted: [String: Any] = [:]
for (key, value) in properties {
for (key, value) in merged {
switch value {
case let s as String: converted[key] = s
case let i as Int: converted[key] = i
@@ -44,10 +52,10 @@ enum AnalyticsTracker {
}
Aptabase.shared.trackEvent(event, with: converted)
#if DEBUG
if properties.isEmpty {
if converted.isEmpty {
NSLog("Analytics: %@", event)
} else {
let formatted = properties
let formatted = converted
.map { "\($0.key)=\($0.value)" }
.sorted()
.joined(separator: ", ")

View File

@@ -351,3 +351,5 @@
"editor.system.voltage_drop.hint.noncritical" = "10 % — for non-critical loads such as cabin lighting.";
"settings.voltage_drop.label" = "Voltage drop budget";
"settings.voltage_drop.footnote" = "Used by the calculator and for new systems. Existing systems keep their own budget.";
"settings.rate.title" = "Rate Cable";
"settings.rate.footnote" = "Ratings are how other installers find Cable in the App Store.";

View File

@@ -242,7 +242,9 @@ struct LoadsView: View {
.onChange(of: previewURL) { _, newValue in
if newValue == nil {
cleanupPreview()
ReviewPrompt.registerSuccessfulExport()
ReviewPrompt.record(.exported)
// The QuickLook sheet is still animating away; `promptIfEligible` waits it out.
ReviewPrompt.promptIfEligible()
}
}
.alert(
@@ -333,6 +335,11 @@ struct LoadsView: View {
.onAppear {
LoadCableSync.synchronize(loads: savedLoads, for: system)
if !savedLoads.isEmpty && loadStatus == nil {
ReviewPrompt.record(.systemPlanned)
}
ReviewPrompt.promptIfEligible()
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async {
@@ -922,6 +929,7 @@ struct LoadsView: View {
"system": system.name
]
)
ReviewPrompt.record(.billOfMaterials)
showingSystemBOM = true
}

View File

@@ -5,7 +5,10 @@
// Decides when to ask the user for an App Store rating via StoreKit's
// `AppStore.requestReview(in:)`. The OS throttles the actual dialog (max ~3×/year and
// may show nothing at all), so this gate keeps requests rare and tied to genuine success
// moments — a completed export/share. Mirrors the Android `ReviewPrompt` object.
// moments. Recording a milestone and asking for the rating are deliberately separate:
// milestones are reached in the middle of a task (a sheet opens, a share sheet closes),
// which is exactly when StoreKit drops the request. `promptIfEligible()` is therefore only
// called from calm screens. Mirrors the Android `ReviewPrompt` object.
//
import Foundation
@@ -13,97 +16,125 @@ import StoreKit
import UIKit
enum ReviewPrompt {
/// Distinct proofs that the user got real value out of the app. Each one counts at most
/// once per install, so navigating in circles cannot inflate the gate.
enum Milestone: String {
/// A system whose loads are all fully configured — the app's own definition of a finished plan.
case systemPlanned
/// Opened a system's bill of materials.
case billOfMaterials
/// Completed an export/share (Overview PDF, BOM PDF, diagram image).
case exported
}
private enum Key {
static let migrationDone = "review.migrationDone"
static let firstLaunchDate = "review.firstLaunchDate"
static let exportCount = "review.successfulExportCount"
static let gateVersion = "review.gateVersion"
static let milestones = "review.milestones"
static let lastPromptDate = "review.lastPromptDate"
static let lastPromptedVersion = "review.lastPromptedVersion"
static let userType = "review.userType"
static let legacyExportCount = "review.successfulExportCount"
static let legacyFirstLaunchDate = "review.firstLaunchDate"
static let legacyMigrationDone = "review.migrationDone"
}
/// Gate thresholds — see CLAUDE-discussed spec.
private static let minExports = 2
private static let minDaysSinceInstall: TimeInterval = 3
private static let minDaysBetweenPrompts: TimeInterval = 120
/// Two *different* milestones — a single one (only opening the parts list, say) is not enough.
private static let minMilestones = 2
private static let minDaysBetweenPrompts: TimeInterval = 90
private static let day: TimeInterval = 86_400
/// Bump when the milestone semantics change so `migrateIfNeeded` runs again.
private static let gateVersion = 2
/// Lets a sheet dismissal finish before StoreKit tries to present on the same scene.
private static let presentationDelay: TimeInterval = 0.7
private static var defaults: UserDefaults { .standard }
/// Deep link for the manual entry point in Settings. Unlike `AppStore.requestReview`, this
/// is never throttled or suppressed, so it is the only path a willing user can always take.
static let writeReviewURL = URL(string: "https://apps.apple.com/app/id6752443870?action=write-review")!
/// One-time setup distinguishing fresh installs from users updating into this feature.
/// Existing users are backdated and pre-seeded so the prompt can fire on their *first*
/// successful export after updating. Pass the `isFirstLaunch` value already computed in
/// `AppDelegate` (the existing `hasLaunchedBefore` flag).
/// Injection seam for tests; production always uses `.standard`.
static var store: UserDefaults = .standard
/// One-time setup, called on every launch. Existing installs keep the credit they earned
/// under the previous export-only gate: the legacy counter was pre-seeded on update, so any
/// non-zero value means "knows the app already" and counts as the export milestone. They
/// still need a second, real milestone before we ask.
static func migrateIfNeeded(isFirstLaunch: Bool) {
guard !defaults.bool(forKey: Key.migrationDone) else { return }
let now = Date().timeIntervalSince1970
if isFirstLaunch {
// Genuine new install: normal flow — needs 2 exports and 3 days.
defaults.set(now, forKey: Key.firstLaunchDate)
defaults.set(0, forKey: Key.exportCount)
defaults.set("new", forKey: Key.userType)
} else {
// Existing user updating in: backdate install past the age gate and pre-seed the
// counter so the very next successful export satisfies the gate.
defaults.set(now - minDaysSinceInstall * day, forKey: Key.firstLaunchDate)
defaults.set(minExports - 1, forKey: Key.exportCount)
defaults.set("existing", forKey: Key.userType)
guard store.integer(forKey: Key.gateVersion) < gateVersion else { return }
if store.object(forKey: Key.userType) == nil {
store.set(isFirstLaunch ? "new" : "existing", forKey: Key.userType)
}
defaults.set(true, forKey: Key.migrationDone)
if store.integer(forKey: Key.legacyExportCount) > 0 {
record(.exported)
}
for key in [Key.legacyExportCount, Key.legacyFirstLaunchDate, Key.legacyMigrationDone] {
store.removeObject(forKey: key)
}
store.set(gateVersion, forKey: Key.gateVersion)
}
/// Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
/// Increments the shared counter, then requests a review if every gate condition holds.
/// Books a success moment. Never presents anything — safe to call from anywhere.
static func record(_ milestone: Milestone) {
var reached = Set(store.stringArray(forKey: Key.milestones) ?? [])
guard reached.insert(milestone.rawValue).inserted else { return }
store.set(reached.sorted(), forKey: Key.milestones)
AnalyticsTracker.log("Review Milestone Reached", properties: [
"milestone": milestone.rawValue,
"reached": reached.count,
])
}
/// Asks for a rating if the gate allows it. Call only from a screen at rest — never while a
/// sheet is being presented or dismissed.
@MainActor
static func registerSuccessfulExport() {
// Guard against an export that races ahead of migration.
if defaults.object(forKey: Key.firstLaunchDate) == nil {
defaults.set(Date().timeIntervalSince1970, forKey: Key.firstLaunchDate)
}
let count = defaults.integer(forKey: Key.exportCount) + 1
defaults.set(count, forKey: Key.exportCount)
guard shouldRequest(exportCount: count) else { return }
request()
static func promptIfEligible() {
guard isEligible else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + presentationDelay) { present() }
}
private static func shouldRequest(exportCount: Int) -> Bool {
// A: enough successful exports
guard exportCount >= minExports else { return false }
/// Internal rather than private so the gate can be tested without a foreground scene.
static var isEligible: Bool {
// A: enough distinct milestones
let reached = store.stringArray(forKey: Key.milestones) ?? []
guard reached.count >= minMilestones else { return false }
// B: not prompted too recently
let now = Date().timeIntervalSince1970
// B: installed long enough
let firstLaunch = defaults.double(forKey: Key.firstLaunchDate)
guard now - firstLaunch >= minDaysSinceInstall * day else { return false }
// C: not prompted too recently
let lastPrompt = defaults.double(forKey: Key.lastPromptDate)
let lastPrompt = store.double(forKey: Key.lastPromptDate)
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
// D: at most once per app version
if defaults.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
// C: at most once per app version
if store.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
return true
}
@MainActor
private static func request() {
// Mark as requested up front — the OS may suppress the dialog, but we still
// count it against our own throttle so we don't ask again immediately.
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
defaults.set(currentVersion, forKey: Key.lastPromptedVersion)
private static func present() {
// Re-check after the delay: the app may have been backgrounded, or another trigger
// may have gotten there first. Only mark the throttle once we really can present —
// otherwise a suppressed request would burn this version's single slot.
guard isEligible, let scene = activeScene else { return }
store.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
store.set(currentVersion, forKey: Key.lastPromptedVersion)
AnalyticsTracker.log("Review Prompt Requested", properties: [
"version": currentVersion,
"userType": defaults.string(forKey: Key.userType) ?? "unknown",
"userType": store.string(forKey: Key.userType) ?? "unknown",
"milestones": (store.stringArray(forKey: Key.milestones) ?? []).joined(separator: ","),
])
guard let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return }
AppStore.requestReview(in: scene)
}
@MainActor
private static var activeScene: UIWindowScene? {
UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
}
private static var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}

View File

@@ -46,6 +46,32 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button {
AnalyticsTracker.log("Rate App Tapped")
openURL(ReviewPrompt.writeReviewURL)
} label: {
Label {
Text(
String(
localized: "settings.rate.title",
defaultValue: "Rate Cable"
)
)
} icon: {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
.accessibilityIdentifier("settings-rate-app")
} footer: {
Text(
String(
localized: "settings.rate.footnote",
defaultValue: "Ratings are how other installers find Cable in the App Store."
)
)
}
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {

View File

@@ -303,7 +303,8 @@ struct SystemBillOfMaterialsView: View {
.accessibilityIdentifier("system-bom-view")
.sheet(item: $activeShareItem, onDismiss: {
cleanupShareItem()
ReviewPrompt.registerSuccessfulExport()
ReviewPrompt.record(.exported)
ReviewPrompt.promptIfEligible()
}) { item in
ShareSheet(items: [item.url])
}

View File

@@ -103,6 +103,7 @@ struct SystemsView: View {
} label: {
Image(systemName: "gearshape")
}
.accessibilityIdentifier("systems-settings")
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {

80
Cable/UsageMetrics.swift Normal file
View File

@@ -0,0 +1,80 @@
//
// UsageMetrics.swift
// Cable
//
// Makes retention measurable although the analytics backend cannot identify a device across
// days: Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that
// rotates every 24 h, so events can never be linked to the previous day's events. Sessions
// expire after an hour of inactivity, so `session_id` cannot bridge days either.
//
// Instead of an identity, every tracked event carries this install's own tenure counters, which
// never leave the device in raw form — only the derived day counts are sent. Exact retention
// curves can then be reconstructed by *counting events* in the export:
//
// installs on a given day launch_no == 1 && tenure_days == 0
// installs active on day k dormant_days >= 1 && tenure_days == k
// D_k retention the latter / the former, k days earlier
//
// `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the
// second line count installs rather than launches.
//
// Days are UTC day indices so they line up with the timestamps in the analytics export.
// Mirrors the Android `UsageMetrics` object, which reports into the same Aptabase project.
//
import Foundation
enum UsageMetrics {
private enum Key {
static let installDay = "usage.installDay"
static let launchCount = "usage.launchCount"
static let activeDays = "usage.activeDays"
static let lastActiveDay = "usage.lastActiveDay"
}
/// Injection seams for tests; production always uses `.standard` and the real clock.
static var store: UserDefaults = .standard
static var clock: () -> Date = Date.init
/// Merged into every event by `AnalyticsTracker.log`. Empty until `beginLaunch` has run.
private(set) static var eventProps: [String: Any] = [:]
/// Advances the counters once per process start and freezes this launch's props.
///
/// `isFirstLaunch` is the app's own install marker (`hasLaunchedBefore`). Installs that
/// predate these counters have no known install date and report `tenure_days == -1` for the
/// rest of their life, so cohort analysis can exclude them instead of mistaking their first
/// instrumented launch for a fresh install.
static func beginLaunch(isFirstLaunch: Bool) {
let today = dayIndex(clock())
if isFirstLaunch, store.object(forKey: Key.installDay) == nil {
store.set(today, forKey: Key.installDay)
}
let launchCount = store.integer(forKey: Key.launchCount) + 1
store.set(launchCount, forKey: Key.launchCount)
// nil on the very first instrumented launch — reported as -1 ("no previous use"), which
// keeps it out of the `dormant_days >= 1` day-boundary count.
let lastActiveDay = store.object(forKey: Key.lastActiveDay) as? Int
let dormantDays = lastActiveDay.map { max(0, today - $0) } ?? -1
if lastActiveDay != today {
store.set(today, forKey: Key.lastActiveDay)
store.set(store.integer(forKey: Key.activeDays) + 1, forKey: Key.activeDays)
}
let installDay = store.object(forKey: Key.installDay) as? Int
eventProps = [
"tenure_days": installDay.map { max(0, today - $0) } ?? -1,
"launch_no": launchCount,
"active_days": store.integer(forKey: Key.activeDays),
"dormant_days": dormantDays,
]
}
/// Whole days since the Unix epoch, in UTC.
static func dayIndex(_ date: Date) -> Int {
Int(floor(date.timeIntervalSince1970 / 86_400))
}
}

View File

@@ -416,3 +416,5 @@
"editor.system.voltage_drop.hint.noncritical" = "10 % — für unkritische Verbraucher wie Innenbeleuchtung.";
"settings.voltage_drop.label" = "Spannungsfall-Budget";
"settings.voltage_drop.footnote" = "Gilt für den Rechner und neue Systeme. Bestehende Systeme behalten ihr eigenes Budget.";
"settings.rate.title" = "Cable bewerten";
"settings.rate.footnote" = "Bewertungen helfen anderen Monteuren, Cable im App Store zu finden.";

View File

@@ -417,3 +417,5 @@
"editor.system.voltage_drop.hint.noncritical" = "10 % — para consumos no críticos como la iluminación interior.";
"settings.voltage_drop.label" = "Caída de tensión admisible";
"settings.voltage_drop.footnote" = "Se aplica a la calculadora y a los sistemas nuevos. Los sistemas existentes conservan su valor.";
"settings.rate.title" = "Valorar Cable";
"settings.rate.footnote" = "Las valoraciones ayudan a que otros instaladores encuentren Cable en el App Store.";

View File

@@ -417,3 +417,5 @@
"editor.system.voltage_drop.hint.noncritical" = "10 % — pour les charges non critiques comme l’éclairage intérieur.";
"settings.voltage_drop.label" = "Chute de tension admissible";
"settings.voltage_drop.footnote" = "S’applique au calculateur et aux nouvelles installations. Les installations existantes gardent leur valeur.";
"settings.rate.title" = "Noter Cable";
"settings.rate.footnote" = "Les avis aident les autres installateurs à trouver Cable sur l’App Store.";

View File

@@ -417,3 +417,5 @@
"editor.system.voltage_drop.hint.noncritical" = "10 % — voor niet-kritische verbruikers zoals binnenverlichting.";
"settings.voltage_drop.label" = "Toegestane spanningsval";
"settings.voltage_drop.footnote" = "Geldt voor de rekenhulp en nieuwe systemen. Bestaande systemen houden hun eigen waarde.";
"settings.rate.title" = "Cable beoordelen";
"settings.rate.footnote" = "Beoordelingen helpen andere installateurs om Cable in de App Store te vinden.";

View File

@@ -0,0 +1,114 @@
import Foundation
import Testing
@testable import Cable
/// The rating gate is invisible in the app — the OS may swallow the dialog — so its rules are
/// only observable here. Serialized because `ReviewPrompt.store` is process-wide state.
@Suite(.serialized)
struct ReviewPromptTests {
private func withFreshStore(_ body: (UserDefaults) -> Void) {
let name = "review.tests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a test defaults suite")
return
}
let previous = ReviewPrompt.store
ReviewPrompt.store = defaults
defer {
ReviewPrompt.store = previous
defaults.removePersistentDomain(forName: name)
}
body(defaults)
}
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
@Test func oneMilestoneIsNotEnough() {
withFreshStore { _ in
ReviewPrompt.record(.billOfMaterials)
#expect(ReviewPrompt.isEligible == false)
}
}
@Test func twoDistinctMilestonesOpenTheGate() {
withFreshStore { _ in
ReviewPrompt.record(.systemPlanned)
ReviewPrompt.record(.billOfMaterials)
#expect(ReviewPrompt.isEligible)
}
}
@Test func repeatingOneMilestoneNeverOpensTheGate() {
withFreshStore { store in
for _ in 0..<5 { ReviewPrompt.record(.systemPlanned) }
#expect(store.stringArray(forKey: "review.milestones") == ["systemPlanned"])
#expect(ReviewPrompt.isEligible == false)
}
}
@Test func aRecentPromptBlocksTheGate() {
withFreshStore { store in
ReviewPrompt.record(.systemPlanned)
ReviewPrompt.record(.exported)
store.set(Date().timeIntervalSince1970 - 10 * 86_400, forKey: "review.lastPromptDate")
#expect(ReviewPrompt.isEligible == false)
store.set(Date().timeIntervalSince1970 - 91 * 86_400, forKey: "review.lastPromptDate")
#expect(ReviewPrompt.isEligible)
}
}
@Test func theCurrentVersionIsOnlyAskedOnce() {
withFreshStore { store in
ReviewPrompt.record(.systemPlanned)
ReviewPrompt.record(.exported)
store.set(appVersion, forKey: "review.lastPromptedVersion")
#expect(ReviewPrompt.isEligible == false)
store.set("0.0.0-old", forKey: "review.lastPromptedVersion")
#expect(ReviewPrompt.isEligible)
}
}
/// Installs that already exported under the previous gate keep that credit, but still need a
/// second, genuine milestone before we ask.
@Test func migrationCarriesLegacyExportCreditAndDropsOldKeys() {
withFreshStore { store in
store.set(1, forKey: "review.successfulExportCount")
store.set(true, forKey: "review.migrationDone")
store.set(Date().timeIntervalSince1970, forKey: "review.firstLaunchDate")
ReviewPrompt.migrateIfNeeded(isFirstLaunch: false)
#expect(store.stringArray(forKey: "review.milestones") == ["exported"])
#expect(store.string(forKey: "review.userType") == "existing")
#expect(store.object(forKey: "review.successfulExportCount") == nil)
#expect(store.object(forKey: "review.migrationDone") == nil)
#expect(store.object(forKey: "review.firstLaunchDate") == nil)
#expect(ReviewPrompt.isEligible == false)
ReviewPrompt.record(.billOfMaterials)
#expect(ReviewPrompt.isEligible)
}
}
@Test func aFreshInstallStartsWithNoCredit() {
withFreshStore { store in
ReviewPrompt.migrateIfNeeded(isFirstLaunch: true)
#expect(store.stringArray(forKey: "review.milestones") == nil)
#expect(store.string(forKey: "review.userType") == "new")
#expect(ReviewPrompt.isEligible == false)
}
}
@Test func theManualReviewLinkTargetsTheStoreListing() {
#expect(
ReviewPrompt.writeReviewURL.absoluteString
== "https://apps.apple.com/app/id6752443870?action=write-review"
)
}
}

View File

@@ -0,0 +1,128 @@
import Foundation
import Testing
@testable import Cable
/// The retention counters are the only way to reconstruct D_k curves from an analytics backend
/// that cannot identify a device across days, so their arithmetic is verified here rather than
/// trusted in production. Serialized because `UsageMetrics.store` is process-wide state.
@Suite(.serialized)
struct UsageMetricsTests {
/// Runs `body` against an isolated defaults suite and a clock the test drives itself.
private func withFreshStore(_ body: (UserDefaults, _ setDay: (Int) -> Void) -> Void) {
let name = "usage.tests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a test defaults suite")
return
}
let previousStore = UsageMetrics.store
let previousClock = UsageMetrics.clock
var day = 20_000
UsageMetrics.store = defaults
UsageMetrics.clock = { Date(timeIntervalSince1970: Double(day) * 86_400 + 3_600) }
defer {
UsageMetrics.store = previousStore
UsageMetrics.clock = previousClock
defaults.removePersistentDomain(forName: name)
}
body(defaults, { day = $0 })
}
private func props() -> [String: Int] {
UsageMetrics.eventProps.compactMapValues { $0 as? Int }
}
@Test func firstLaunchOfANewInstallStartsTheCounters() {
withFreshStore { _, _ in
UsageMetrics.beginLaunch(isFirstLaunch: true)
#expect(props() == [
"tenure_days": 0, "launch_no": 1, "active_days": 1, "dormant_days": -1,
])
}
}
@Test func furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() {
withFreshStore { _, _ in
UsageMetrics.beginLaunch(isFirstLaunch: true)
UsageMetrics.beginLaunch(isFirstLaunch: false)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": 0, "launch_no": 3, "active_days": 1, "dormant_days": 0,
])
}
}
@Test func tenureAndActiveDaysAdvanceAcrossDays() {
withFreshStore { _, setDay in
UsageMetrics.beginLaunch(isFirstLaunch: true)
setDay(20_001)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": 1, "launch_no": 2, "active_days": 2, "dormant_days": 1,
])
setDay(20_007)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": 7, "launch_no": 3, "active_days": 3, "dormant_days": 6,
])
}
}
/// The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly
/// one launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in
/// the export measure launches instead of installs.
@Test func exactlyOneLaunchPerDayMarksTheDayBoundary() {
withFreshStore { _, setDay in
var installMarkers = 0
var dayMarkers = 0
var isFirst = true
for day in 20_000...20_004 {
setDay(day)
for _ in 0..<3 {
UsageMetrics.beginLaunch(isFirstLaunch: isFirst)
isFirst = false
if props()["launch_no"] == 1 { installMarkers += 1 }
if props()["dormant_days", default: 0] >= 1 { dayMarkers += 1 }
}
}
#expect(installMarkers == 1)
// Day 0 is counted by the install marker, so the boundary marks days 1...4.
#expect(dayMarkers == 4)
#expect(props()["active_days"] == 5)
#expect(props()["launch_no"] == 15)
}
}
/// Installs that predate the counters have no install date. They must stay distinguishable
/// from fresh installs forever, otherwise the update inflates the new-install cohort.
@Test func installsPredatingTheCountersReportUnknownTenure() {
withFreshStore { _, setDay in
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": -1, "launch_no": 1, "active_days": 1, "dormant_days": -1,
])
setDay(20_003)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": -1, "launch_no": 2, "active_days": 2, "dormant_days": 3,
])
}
}
@Test func aBackwardsClockNeverProducesNegativeCounts() {
withFreshStore { _, setDay in
setDay(20_010)
UsageMetrics.beginLaunch(isFirstLaunch: true)
setDay(20_002)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props()["tenure_days"] == 0)
#expect(props()["dormant_days"] == 0)
}
}
@Test func dayIndexIsAUTCDayCount() {
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 0)) == 0)
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_399)) == 0)
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_400)) == 1)
}
}

View File

@@ -0,0 +1,37 @@
import XCTest
/// The manual rating entry point is the only path a willing user can always take — StoreKit's
/// dialog may be suppressed by the OS. If this row disappears, ratings stop entirely.
final class SettingsRateAppUITests: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
}
@MainActor
func testSettingsOffersARateAppRow() throws {
let app = XCUIApplication()
app.launchArguments = [
"--uitest-reset-data",
"--uitest-sample-data",
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
]
app.launch()
let settingsButton = app.buttons["systems-settings"]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 15))
settingsButton.tap()
let rateRow = app.buttons["settings-rate-app"]
XCTAssertTrue(
rateRow.waitForExistence(timeout: 10),
"Settings no longer offers a way to rate the app"
)
XCTAssertTrue(
rateRow.label.contains("Rate Cable"),
"Rate row shows \"\(rateRow.label)\" instead of the localized title"
)
}
}

View File

@@ -20,14 +20,14 @@ val hasReleaseSigning = keystoreProps.getProperty("storeFile")?.let { file(it).e
android {
namespace = "app.voltplan.cable"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "app.voltplan.cable"
minSdk = 26
targetSdk = 35
versionCode = 87
versionName = "1.8.0"
targetSdk = 36
versionCode = 88
versionName = "1.8.1"
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
@@ -122,6 +122,8 @@ dependencies {
// Installs the bundled baseline profile on devices that do not get it from Play.
implementation(libs.androidx.profileinstaller)
testImplementation(libs.junit)
// Consumes the profile produced by :baselineprofile.
baselineProfile(project(":baselineprofile"))
}

View File

@@ -2,6 +2,7 @@ package app.voltplan.cable
import android.app.Application
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.analytics.UsageMetrics
import app.voltplan.cable.data.CableRepository
import app.voltplan.cable.data.ReviewPrompt
import app.voltplan.cable.data.UnitSystemSettings
@@ -26,6 +27,8 @@ class CableApplication : Application() {
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
val isFirstLaunch = settings.consumeFirstLaunch()
// Before the first log call: every event carries this launch's tenure counters.
UsageMetrics.beginLaunch(this@CableApplication, isFirstLaunch)
if (isFirstLaunch) {
Analytics.log("First Launch")
}

View File

@@ -78,17 +78,19 @@ object Analytics {
/** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */
fun log(event: String, properties: Map<String, Any?> = emptyMap()) {
// Tenure counters first so an explicit property of the same name would win.
val merged = UsageMetrics.eventProps + properties
if (BuildConfig.DEBUG) {
if (properties.isEmpty()) {
if (merged.isEmpty()) {
Log.d(TAG, "Analytics: $event")
} else {
val formatted = properties.entries.sortedBy { it.key }
val formatted = merged.entries.sortedBy { it.key }
.joinToString(", ") { "${it.key}=${it.value}" }
Log.d(TAG, "Analytics: $event { $formatted }")
}
}
val props = buildJsonObject {
for ((key, value) in properties) {
for ((key, value) in merged) {
when (value) {
null -> {}
is String -> put(key, value)

View File

@@ -0,0 +1,114 @@
package app.voltplan.cable.analytics
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import app.voltplan.cable.data.dataStore
/**
* Makes retention measurable although the analytics backend cannot identify a device across days:
* Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that rotates
* every 24 h, so events can never be linked to the previous day's events. Sessions expire after an
* hour of inactivity, so `sessionId` cannot bridge days either.
*
* Instead of an identity, every tracked event carries this install's own tenure counters, which
* never leave the device in raw form — only the derived day counts are sent. Exact retention
* curves can then be reconstructed by *counting events* in the export:
*
* installs on a given day launch_no == 1 && tenure_days == 0
* installs active on day k dormant_days >= 1 && tenure_days == k
* D_k retention the latter / the former, k days earlier
*
* `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the
* second line count installs rather than launches.
*
* Days are UTC day indices so they line up with the timestamps in the analytics export.
* Mirrors the iOS `UsageMetrics` enum, which reports into the same Aptabase project. The counter
* arithmetic lives in the pure [advance] so it can be tested without an Android context; iOS tests
* the same rules through its injectable `UserDefaults`.
*/
object UsageMetrics {
private val INSTALL_DAY = intPreferencesKey("usage.installDay")
private val LAUNCH_COUNT = intPreferencesKey("usage.launchCount")
private val ACTIVE_DAYS = intPreferencesKey("usage.activeDays")
private val LAST_ACTIVE_DAY = intPreferencesKey("usage.lastActiveDay")
private const val DAY_MS = 86_400_000L
/** Persisted counters. `installDay` and `lastActiveDay` are null until the first launch. */
internal data class State(
val installDay: Int? = null,
val launchCount: Int = 0,
val activeDays: Int = 0,
val lastActiveDay: Int? = null,
)
internal data class Launch(val state: State, val props: Map<String, Any>)
/** Merged into every event by [Analytics.log]. Empty until [beginLaunch] has run. */
@Volatile
var eventProps: Map<String, Any> = emptyMap()
private set
/**
* Advances the counters once per process start and freezes this launch's props.
*
* [isFirstLaunch] is the app's own install marker (`hasLaunchedBefore`, consumed by
* `UnitSystemSettings.consumeFirstLaunch`). Installs that predate these counters have no known
* install date and report `tenure_days == -1` for the rest of their life, so cohort analysis
* can exclude them instead of mistaking their first instrumented launch for a fresh install.
*/
suspend fun beginLaunch(
context: Context,
isFirstLaunch: Boolean,
nowMillis: Long = System.currentTimeMillis(),
) {
val today = dayIndex(nowMillis)
var props: Map<String, Any> = emptyMap()
context.dataStore.edit { prefs ->
val launch = advance(
State(
installDay = prefs[INSTALL_DAY],
launchCount = prefs[LAUNCH_COUNT] ?: 0,
activeDays = prefs[ACTIVE_DAYS] ?: 0,
lastActiveDay = prefs[LAST_ACTIVE_DAY],
),
isFirstLaunch,
today,
)
launch.state.installDay?.let { prefs[INSTALL_DAY] = it }
prefs[LAUNCH_COUNT] = launch.state.launchCount
prefs[ACTIVE_DAYS] = launch.state.activeDays
launch.state.lastActiveDay?.let { prefs[LAST_ACTIVE_DAY] = it }
props = launch.props
}
eventProps = props
}
/** Pure counter arithmetic: the new state plus the props this launch reports. */
internal fun advance(state: State, isFirstLaunch: Boolean, today: Int): Launch {
val installDay = state.installDay ?: today.takeIf { isFirstLaunch }
val launchCount = state.launchCount + 1
// null on the very first instrumented launch — reported as -1 ("no previous use"), which
// keeps it out of the `dormant_days >= 1` day-boundary count.
val dormantDays = state.lastActiveDay?.let { maxOf(0, today - it) } ?: -1
val isNewDay = state.lastActiveDay != today
val activeDays = if (isNewDay) state.activeDays + 1 else state.activeDays
return Launch(
State(installDay, launchCount, activeDays, if (isNewDay) today else state.lastActiveDay),
mapOf(
"tenure_days" to (installDay?.let { maxOf(0, today - it) } ?: -1),
"launch_no" to launchCount,
"active_days" to activeDays,
"dormant_days" to dormantDays,
),
)
}
/** Whole days since the Unix epoch, in UTC. */
fun dayIndex(epochMillis: Long): Int = Math.floorDiv(epochMillis, DAY_MS).toInt()
}

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import app.voltplan.cable.BuildConfig
@@ -20,109 +21,144 @@ import kotlinx.coroutines.flow.first
/**
* Decides when to ask the user for a Play Store rating via the Play In-App Review API.
* Google throttles the actual dialog (and shows nothing in debug/sideload builds), so this gate
* keeps requests rare and tied to genuine success moments — a completed export/share.
* Mirrors the iOS `ReviewPrompt` enum, sharing the same gate thresholds and the `cable_settings`
* DataStore so both platforms behave identically.
* keeps requests rare and tied to genuine success moments. Booking a milestone and asking for the
* rating are deliberately separate: milestones are reached mid-task (a screen opens, a share
* intent fires), which is exactly when the review flow gets dropped. [promptIfEligible] is
* therefore only called from calm screens.
* Mirrors the iOS `ReviewPrompt` enum, sharing the same milestones and thresholds and the
* `cable_settings` DataStore so both platforms behave identically.
*/
object ReviewPrompt {
private val MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
private val FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
private val EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
/**
* Distinct proofs that the user got real value out of the app. Each one counts at most once
* per install, so navigating in circles cannot inflate the gate.
*/
enum class Milestone(val key: String) {
/** A system whose loads are all fully configured — the app's own definition of a finished plan. */
SYSTEM_PLANNED("systemPlanned"),
/** Opened a system's bill of materials. */
BILL_OF_MATERIALS("billOfMaterials"),
/** Completed an export/share (Overview PDF, BOM PDF, diagram image). */
EXPORTED("exported"),
}
private val GATE_VERSION = intPreferencesKey("review.gateVersion")
private val MILESTONES = stringSetPreferencesKey("review.milestones")
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
private val USER_TYPE = stringPreferencesKey("review.userType")
private val LEGACY_EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
private val LEGACY_FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
private val LEGACY_MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
private const val MIN_EXPORTS = 2
private const val MIN_DAYS_SINCE_INSTALL = 3L
private const val MIN_DAYS_BETWEEN_PROMPTS = 120L
/** Two *different* milestones — a single one (only opening the parts list, say) is not enough. */
private const val MIN_MILESTONES = 2
private const val MIN_DAYS_BETWEEN_PROMPTS = 90L
private const val DAY_MS = 24L * 60 * 60 * 1000
/** Bump when the milestone semantics change so [migrateIfNeeded] runs again. */
private const val CURRENT_GATE_VERSION = 2
/** Play listing, used by the manual entry point in Settings. Unlike the in-app review flow this
* is never throttled or suppressed, so it is the only path a willing user can always take. */
const val PLAY_STORE_URI = "market://details?id=app.voltplan.cable"
const val PLAY_STORE_WEB_URL = "https://play.google.com/store/apps/details?id=app.voltplan.cable"
/**
* One-time setup distinguishing fresh installs from users updating into this feature.
* Existing users are backdated and pre-seeded so the prompt can fire on their *first*
* successful export after updating. Pass the value returned by [UnitSystemSettings.consumeFirstLaunch].
* One-time setup, called on every launch. Existing installs keep the credit they earned under
* the previous export-only gate: the legacy counter was pre-seeded on update, so any non-zero
* value means "knows the app already" and counts as the export milestone. They still need a
* second, real milestone before we ask. Pass the value returned by
* [UnitSystemSettings.consumeFirstLaunch].
*/
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
if (context.dataStore.data.first()[MIGRATION_DONE] != null) return
val now = System.currentTimeMillis()
val prefs = context.dataStore.data.first()
if ((prefs[GATE_VERSION] ?: 0) >= CURRENT_GATE_VERSION) return
if ((prefs[LEGACY_EXPORT_COUNT] ?: 0) > 0) {
record(context, Milestone.EXPORTED)
}
context.dataStore.edit {
if (isFirstLaunch) {
// Genuine new install: normal flow — needs 2 exports and 3 days.
it[FIRST_LAUNCH_DATE] = now
it[EXPORT_COUNT] = 0
it[USER_TYPE] = "new"
} else {
// Existing user updating in: backdate install past the age gate and pre-seed the
// counter so the very next successful export satisfies the gate.
it[FIRST_LAUNCH_DATE] = now - MIN_DAYS_SINCE_INSTALL * DAY_MS
it[EXPORT_COUNT] = MIN_EXPORTS - 1
it[USER_TYPE] = "existing"
if (it[USER_TYPE] == null) it[USER_TYPE] = if (isFirstLaunch) "new" else "existing"
it.remove(LEGACY_EXPORT_COUNT)
it.remove(LEGACY_FIRST_LAUNCH_DATE)
it.remove(LEGACY_MIGRATION_DONE)
it[GATE_VERSION] = CURRENT_GATE_VERSION
}
it[MIGRATION_DONE] = "true"
}
/** Books a success moment. Never shows anything — safe to call from anywhere. */
suspend fun record(context: Context, milestone: Milestone) {
var reached = emptySet<String>()
var added = false
context.dataStore.edit {
val current = it[MILESTONES] ?: emptySet()
added = milestone.key !in current
reached = current + milestone.key
if (added) it[MILESTONES] = reached
}
if (!added) return
Analytics.log(
"Review Milestone Reached",
mapOf("milestone" to milestone.key, "reached" to reached.size),
)
}
/**
* Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
* Increments the shared counter, then requests a review if every gate condition holds.
* Asks for a rating if the gate allows it. Call only from a screen at rest — never right
* before starting an activity.
*/
suspend fun registerSuccessfulExport(context: Context) {
var count = 0
var firstLaunch = 0L
context.dataStore.edit {
if (it[FIRST_LAUNCH_DATE] == null) it[FIRST_LAUNCH_DATE] = System.currentTimeMillis()
count = (it[EXPORT_COUNT] ?: 0) + 1
it[EXPORT_COUNT] = count
firstLaunch = it[FIRST_LAUNCH_DATE] ?: 0L
}
if (shouldRequest(context, count, firstLaunch)) {
requestReview(context)
}
}
suspend fun promptIfEligible(context: Context) {
if (!isEligible(context)) return
private suspend fun shouldRequest(context: Context, exportCount: Int, firstLaunch: Long): Boolean {
// A: enough successful exports
if (exportCount < MIN_EXPORTS) return false
val activity = context.findActivity() ?: return
val manager = ReviewManagerFactory.create(context)
// Play decides whether there is a flow to show at all; asking first means a suppressed
// request never burns this version's single slot.
val reviewInfo = runCatching { manager.requestReview() }.getOrNull() ?: return
val now = System.currentTimeMillis()
// B: installed long enough
if (now - firstLaunch < MIN_DAYS_SINCE_INSTALL * DAY_MS) return false
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet that was
// just launched (startActivity returns immediately, so we may still be paused).
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
?.first()
val prefs = context.dataStore.data.first()
// C: not prompted too recently
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
// D: at most once per app version
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
return true
}
private suspend fun requestReview(context: Context) {
// Mark as requested up front — Google may suppress the dialog, but we still count it
// against our own throttle so we don't ask again immediately.
context.dataStore.edit {
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
}
val userType = context.dataStore.data.first()[USER_TYPE] ?: "unknown"
Analytics.log(
"Review Prompt Requested",
mapOf("version" to BuildConfig.VERSION_NAME, "userType" to userType),
mapOf(
"version" to BuildConfig.VERSION_NAME,
"userType" to (prefs[USER_TYPE] ?: "unknown"),
"milestones" to (prefs[MILESTONES] ?: emptySet()).sorted().joinToString(","),
),
)
val activity = context.findActivity() ?: return
runCatching {
val manager = ReviewManagerFactory.create(context)
val reviewInfo = manager.requestReview()
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet
// that was just launched (startActivity returns immediately, so we may still be paused).
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
?.first()
manager.launchReview(activity, reviewInfo)
runCatching { manager.launchReview(activity, reviewInfo) }
}
/** Internal rather than private so the gate can be tested without an Activity. */
internal suspend fun isEligible(context: Context): Boolean {
val prefs = context.dataStore.data.first()
// A: enough distinct milestones
if ((prefs[MILESTONES] ?: emptySet()).size < MIN_MILESTONES) return false
// B: not prompted too recently
val now = System.currentTimeMillis()
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
// C: at most once per app version
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
return true
}
private fun Context.findActivity(): Activity? {

View File

@@ -77,7 +77,8 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
vm.logPdfExported()
scope.launch {
SystemBomPdf.exportAndShare(context, state, unit)
ReviewPrompt.registerSuccessfulExport(context)
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
ReviewPrompt.promptIfEligible(context)
}
},
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }

View File

@@ -1,5 +1,9 @@
package app.voltplan.cable.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,9 +13,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
@@ -25,18 +31,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.ReviewPrompt
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysYellow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val settings = LocalUnitSettings.current
val unit by settings.unitSystem.collectAsStateWithLifecycle()
@@ -68,6 +80,33 @@ fun SettingsScreen(onBack: () -> Unit) {
)
}
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
Analytics.log("Rate App Tapped")
openPlayStoreListing(context)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Star, contentDescription = null, tint = SysYellow, modifier = Modifier.size(20.dp))
Column {
Text(
stringResource(R.string.settings_rate_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.settings_rate_footnote),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.settings_disclaimer_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
@@ -77,3 +116,12 @@ fun SettingsScreen(onBack: () -> Unit) {
}
}
}
/** Opens the Play listing, falling back to the web listing on devices without the Play app. */
private fun openPlayStoreListing(context: Context) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ReviewPrompt.PLAY_STORE_URI)))
}.recoverCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ReviewPrompt.PLAY_STORE_WEB_URL)))
}
}

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.IosShare
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -110,6 +111,15 @@ fun SystemDetailScreen(
var diagramBitmapPreview by remember { mutableStateOf<Bitmap?>(null) }
val system = state.system
// A finished plan is the app's own definition of success, and the overview is the calm moment
// where a rating request can actually be presented.
val systemPlanned = state.loads.isNotEmpty() &&
state.loads.all { it.length > 0 && it.current > 0 && it.crossSection > 0 }
LaunchedEffect(systemPlanned) {
if (systemPlanned) ReviewPrompt.record(context, ReviewPrompt.Milestone.SYSTEM_PLANNED)
ReviewPrompt.promptIfEligible(context)
}
// Switch to the matching tab before opening an editor, so returning from the
// editor lands on that tab with the newly created component visible.
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
@@ -184,7 +194,8 @@ fun SystemDetailScreen(
exporting = true
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
exporting = false
ReviewPrompt.registerSuccessfulExport(context)
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
ReviewPrompt.promptIfEligible(context)
}
},
)
@@ -221,7 +232,11 @@ fun SystemDetailScreen(
onAddBattery = newBattery,
onAddCharger = newCharger,
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
onOpenBom = { vm.logBomOpened(); onOpenBom() },
onOpenBom = {
vm.logBomOpened()
scope.launch { ReviewPrompt.record(context, ReviewPrompt.Milestone.BILL_OF_MATERIALS) }
onOpenBom()
},
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
@@ -261,7 +276,8 @@ fun SystemDetailScreen(
scope.launch {
SystemDiagram.share(context, bmp, state.system?.name ?: "System")
diagramBitmapPreview = null
ReviewPrompt.registerSuccessfulExport(context)
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
ReviewPrompt.promptIfEligible(context)
}
},
onDismiss = { diagramBitmapPreview = null },

View File

@@ -259,6 +259,8 @@
<string name="settings_units_section">Einheiten</string>
<string name="units_metric_display">Metrisch (mm², m)</string>
<string name="units_imperial_display">Imperial (AWG, ft)</string>
<string name="settings_rate_title">Cable bewerten</string>
<string name="settings_rate_footnote">Bewertungen helfen anderen Monteuren, Cable im Play Store zu finden.</string>
<string name="settings_disclaimer_title">Sicherheitshinweis</string>
<string name="settings_disclaimer_body">Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.</string>
<string name="settings_disclaimer_points">• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu\n• Beachte alle örtlichen Vorschriften und Normen\n• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden\n• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren\n• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen</string>

View File

@@ -259,6 +259,8 @@
<string name="settings_units_section">Unidades</string>
<string name="units_metric_display">Métrico (mm², m)</string>
<string name="units_imperial_display">Imperial (AWG, ft)</string>
<string name="settings_rate_title">Valorar Cable</string>
<string name="settings_rate_footnote">Las valoraciones ayudan a que otros instaladores encuentren Cable en Play Store.</string>
<string name="settings_disclaimer_title">Aviso de seguridad</string>
<string name="settings_disclaimer_body">Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.</string>
<string name="settings_disclaimer_points">• Consulta siempre a electricistas calificados para las instalaciones reales\n• Cumple todas las normativas y códigos eléctricos locales\n• Los trabajos eléctricos solo deben realizarlos profesionales autorizados\n• Estos cálculos pueden no tener en cuenta todos los factores ambientales\n• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas</string>

View File

@@ -259,6 +259,8 @@
<string name="settings_units_section">Unités</string>
<string name="units_metric_display">Métrique (mm², m)</string>
<string name="units_imperial_display">Impérial (AWG, ft)</string>
<string name="settings_rate_title">Noter Cable</string>
<string name="settings_rate_footnote">Les avis aident les autres installateurs à trouver Cable sur le Play Store.</string>
<string name="settings_disclaimer_title">Avertissement de sécurité</string>
<string name="settings_disclaimer_body">Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation.</string>
<string name="settings_disclaimer_points">• Faites toujours appel à des électriciens qualifiés pour les installations réelles\n• Respectez toutes les normes et réglementations électriques locales\n• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés\n• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux\n• Les développeurs de l\'application déclinent toute responsabilité quant aux installations électriques</string>

View File

@@ -259,6 +259,8 @@
<string name="settings_units_section">Eenheden</string>
<string name="units_metric_display">Metrisch (mm², m)</string>
<string name="units_imperial_display">Imperiaal (AWG, ft)</string>
<string name="settings_rate_title">Cable beoordelen</string>
<string name="settings_rate_footnote">Beoordelingen helpen andere installateurs om Cable in de Play Store te vinden.</string>
<string name="settings_disclaimer_title">Veiligheidswaarschuwing</string>
<string name="settings_disclaimer_body">Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.</string>
<string name="settings_disclaimer_points">• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien\n• Volg alle lokale elektrische voorschriften en regels\n• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals\n• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening\n• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties</string>

View File

@@ -259,6 +259,8 @@
<string name="settings_units_section">Units</string>
<string name="units_metric_display">Metric (mm², m)</string>
<string name="units_imperial_display">Imperial (AWG, ft)</string>
<string name="settings_rate_title">Rate Cable</string>
<string name="settings_rate_footnote">Ratings are how other installers find Cable in the Play Store.</string>
<string name="settings_disclaimer_title">Safety Disclaimer</string>
<string name="settings_disclaimer_body">This application provides electrical calculations for educational and estimation purposes only.</string>
<string name="settings_disclaimer_points">• Always consult qualified electricians for actual installations\n• Follow all local electrical codes and regulations\n• Electrical work should only be performed by licensed professionals\n• These calculations may not account for all environmental factors\n• The app developers assume no liability for electrical installations</string>

View File

@@ -0,0 +1,116 @@
package app.voltplan.cable.analytics
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The retention counters are the only way to reconstruct D_k curves from an analytics backend that
* cannot identify a device across days, so their arithmetic is verified here rather than trusted in
* production. Mirrors `CableTests/UsageMetricsTests.swift`.
*/
class UsageMetricsTest {
private fun props(vararg pairs: Pair<String, Any>) = mapOf(*pairs)
@Test
fun firstLaunchOfANewInstallStartsTheCounters() {
val launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000)
assertEquals(
props("tenure_days" to 0, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1),
launch.props,
)
assertEquals(UsageMetrics.State(20_000, 1, 1, 20_000), launch.state)
}
@Test
fun furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() {
var state = UsageMetrics.State()
var props: Map<String, Any> = emptyMap()
repeat(3) { index ->
val launch = UsageMetrics.advance(state, isFirstLaunch = index == 0, today = 20_000)
state = launch.state
props = launch.props
}
assertEquals(
props("tenure_days" to 0, "launch_no" to 3, "active_days" to 1, "dormant_days" to 0),
props,
)
}
@Test
fun tenureAndActiveDaysAdvanceAcrossDays() {
var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000)
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_001)
assertEquals(
props("tenure_days" to 1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 1),
launch.props,
)
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_007)
assertEquals(
props("tenure_days" to 7, "launch_no" to 3, "active_days" to 3, "dormant_days" to 6),
launch.props,
)
}
/**
* The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly one
* launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in the
* export measure launches instead of installs.
*/
@Test
fun exactlyOneLaunchPerDayMarksTheDayBoundary() {
var state = UsageMetrics.State()
var installMarkers = 0
var dayMarkers = 0
var isFirst = true
var props: Map<String, Any> = emptyMap()
for (day in 20_000..20_004) {
repeat(3) {
val launch = UsageMetrics.advance(state, isFirst, day)
isFirst = false
state = launch.state
props = launch.props
if (props["launch_no"] == 1) installMarkers++
if ((props["dormant_days"] as Int) >= 1) dayMarkers++
}
}
assertEquals(1, installMarkers)
// Day 0 is counted by the install marker, so the boundary marks days 1..4.
assertEquals(4, dayMarkers)
assertEquals(5, props["active_days"])
assertEquals(15, props["launch_no"])
}
/**
* Installs that predate the counters have no install date. They must stay distinguishable from
* fresh installs forever, otherwise the update inflates the new-install cohort.
*/
@Test
fun installsPredatingTheCountersReportUnknownTenure() {
var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = false, today = 20_000)
assertEquals(
props("tenure_days" to -1, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1),
launch.props,
)
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_003)
assertEquals(
props("tenure_days" to -1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 3),
launch.props,
)
}
@Test
fun aBackwardsClockNeverProducesNegativeCounts() {
val first = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_010)
val second = UsageMetrics.advance(first.state, isFirstLaunch = false, today = 20_002)
assertEquals(0, second.props["tenure_days"])
assertEquals(0, second.props["dormant_days"])
}
@Test
fun dayIndexIsAUtcDayCount() {
assertEquals(0, UsageMetrics.dayIndex(0L))
assertEquals(0, UsageMetrics.dayIndex(86_399_000L))
assertEquals(1, UsageMetrics.dayIndex(86_400_000L))
}
}

View File

@@ -6,7 +6,7 @@ plugins {
android {
namespace = "app.voltplan.cable.baselineprofile"
compileSdk = 35
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
@@ -19,7 +19,7 @@ android {
defaultConfig {
// Baseline profile capture needs API 33+ on an unrooted device.
minSdk = 33
targetSdk = 35
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.7.3"
agp = "8.10.1"
kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
coreKtx = "1.15.0"
@@ -46,6 +46,7 @@ play-review-ktx = { group = "com.google.android.play", name = "review-ktx", vers
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
junit = { group = "junit", name = "junit", version = "4.13.2" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version = "2.3.0" }
[plugins]