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
1 change: 1 addition & 0 deletions .coverage-visibility-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# Abstract interfaces / ports (implementations live elsewhere and are tested):
lib/packages/io/backup_exclusion_port.dart
lib/packages/io/documents_directory_port.dart
lib/packages/io/install_referrer_port.dart
lib/packages/service/biometric/biometric_port.dart
lib/packages/service/price_service.dart
lib/screens/kyc/steps/ident/cubits/kyc_ident/sumsub_ident_port.dart
Expand Down
25 changes: 11 additions & 14 deletions .github/workflows/handbook-build-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ jobs:
set -euo pipefail
bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots
count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ')
if [ "$count" != "284" ]; then
echo "expected 284 screenshots, got $count" >&2
if [ "$count" != "299" ]; then
echo "expected 299 screenshots, got $count" >&2
exit 1
fi

Expand Down Expand Up @@ -173,15 +173,13 @@ jobs:
exit 1
fi

# Screenshots dir must contain all 284 PNGs assembled from Goldens.
# Hit one of them through the auth gate to verify wiring end-to-end.
# Mix of the original 01-61 range and the 62-269 batch (every Golden
# baseline) so a regression in either half surfaces here.
for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j; do
code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png")
# 200 (auth happens to match) or 401 (auth fails but file exists)
# both prove the file is on disk. 404 means it was not assembled.
if [ "$code" = "404" ]; then
# Spot-check handbook screenshots assembled from Goldens (299 PNGs).
# Mix of original 01-61 slots, later Golden batches, and referral
# baselines so a regression in any range surfaces here.
for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo; do
# Auth is checked before the file exists: a missing PNG also
# returns 401, so HTTP status cannot prove assembly. Look on disk.
if ! docker exec handbook test -f "/usr/share/nginx/html/screenshots/${name}.png"; then
echo "screenshot ${name}.png missing from /usr/share/nginx/html/screenshots/" >&2
docker logs handbook
exit 1
Expand All @@ -191,9 +189,8 @@ jobs:
# Legal downloads must be present (built by the legal-docs-builder
# stage via pandoc). Same 200/401-vs-404 logic: 404 means the PDF/DOCX
# was not produced. Covers PDF + DOCX and both languages.
for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx; do
code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/${f}")
if [ "$code" = "404" ]; then
for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx legal/referral_terms_de.pdf legal/referral_terms_en.pdf legal/referral_terms_de.docx legal/referral_terms_en.docx; do
if ! docker exec handbook test -f "/usr/share/nginx/html/${f}"; then
echo "legal download ${f} missing from /usr/share/nginx/html/legal/" >&2
docker logs handbook
exit 1
Expand Down
6 changes: 6 additions & 0 deletions .maestro/handbook/11-dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ appId: swiss.realunit.app
- extendedWaitUntil:
visible: 'RealUnit kaufen'
timeout: 30000
# Fresh handbook wallet is not a verified shareholder with 70 REALU
# (TB Ziff. 2, analog Verkaufen). The dashboard card must stay off this
# screen; 289-dashboard-referral-entry-and-payout is golden-only.
- extendedWaitUntil:
notVisible: 'Erhalte 20 REALU pro Weiterempfehlung'
timeout: 10000
10 changes: 10 additions & 0 deletions .maestro/handbook/12-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,13 @@ appId: swiss.realunit.app
- extendedWaitUntil:
visible: 'Einstellungen'
timeout: 30000
# Same gate as the dashboard card. Wait for a settings tile that does not
# depend on the referral API, then assert Empfehlungen is still absent so
# a late summary 200 cannot have opened the tile on a fresh wallet.
- extendedWaitUntil:
visible:
text: '.*Rechtsdokumente.*'
timeout: 15000
- extendedWaitUntil:
notVisible: 'Empfehlungen'
timeout: 8000
17 changes: 17 additions & 0 deletions .maestro/handbook/20-settings-legal-documents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,20 @@ appId: swiss.realunit.app
visible:
text: '.*Dokumente der Aktionariat AG.*'
timeout: 30000
# Last tile is the 14.08 Teilnahmebedingungen (Ziff. 2–11). On a phone it can
# sit below the fold, so scroll it into view, then restore Aktionariat so
# flow 21 can tap that entry.
- scrollUntilVisible:
element:
text: '.*Teilnahmebedingungen Referral-Programm.*'
direction: DOWN
timeout: 15000
- extendedWaitUntil:
visible:
text: '.*Teilnahmebedingungen Referral-Programm.*'
timeout: 10000
- scrollUntilVisible:
element:
text: '.*Dokumente der Aktionariat AG.*'
direction: UP
timeout: 15000
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Non-BitBox code only needs Tier 0 + widget tests; Tier 1+ are reserved for hardw
| Coverage | `flutter test --coverage` | Writes `coverage/lcov.info`. CI narrows it to the activated surface and hard-fails when scoped coverage drops below the floor in `.coverage-floor-lines` / `.coverage-floor-functions`. See "Coverage infrastructure roadmap" above for the ratchet protocol. |
| Analyzer | `flutter analyze` | Dart static analysis per `analysis_options.yaml` |

Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred.
Tier 1 specs live under `test/integration/**` and run inside the same `flutter test --coverage` invocation as Tier 0 — no separate `integration_test/` harness today (that Flutter-convention directory is reserved for on-device runs that are not yet wired up). Referral widget flows live under `test/screens/referral/` (including the former `integration_test/referral_*_e2e_test.dart` specs, now at `test/screens/referral/flows/`) and run in that same coverage invocation. Tier 3 handbook flows (iOS Simulator) are wired via [`tier3-handbook.yaml`](.github/workflows/tier3-handbook.yaml); the BitBox02 hardware variant remains deferred.

## CI/CD

Expand Down
6 changes: 6 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ linter:
avoid_print: true
prefer_const_constructors: true
prefer_single_quotes: true
analyzer:
exclude:
- build/**
- android/**
- ios/**
- macos/**
6 changes: 5 additions & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "swiss.realunit.app"
compileSdk = 36
ndkVersion = "29.0.13113456 rc1"
ndkVersion = "29.0.13113456"

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
Expand Down Expand Up @@ -64,3 +64,7 @@ android {
flutter {
source = "../.."
}

dependencies {
implementation "com.android.installreferrer:installreferrer:2.2"
}
7 changes: 5 additions & 2 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@
-keep class com.google.android.gms.tasks.** { *; }

# SumSub SDK general keep rules
-keep class com.sumsub.** { *; }
-dontwarn com.sumsub.**
-keep class com.sumsub.** { *; }
-dontwarn com.sumsub.**

# Play Install Referrer (invite code across a fresh Android install)
-keep class com.android.installreferrer.** { *; }
28 changes: 28 additions & 0 deletions android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,32 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Staging App Links. Kept out of the release autoVerify filter so a
missing https://dev.realunit.app/.well-known/assetlinks.json cannot
fail Play verification for realunit.app. -->
<application>
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:pathPrefix="/invite/"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:pathPrefix="/promo/"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:path="/invite"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:path="/promo"/>
</intent-filter>
</activity>
</application>
</manifest>
59 changes: 58 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,70 @@
realunit-wallet://open). Opening the app via the scheme only
foregrounds it; go_router redirects any scheme URL to the normal
entry. A custom scheme needs no autoVerify / Digital Asset
Links. -->
Links. Multiple <data> in one filter are sets (scheme × host ×
path), not tuples: a pathPrefix here would require a path and
drop realunit-wallet://open. Opaque URIs (lightning, invite/code
without //) have no host and need a scheme-only filter. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="realunit-wallet" android:host="open"/>
<data android:scheme="realunit-wallet" android:host="invite"/>
<data android:scheme="realunit-wallet" android:host="promo"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="realunit-wallet"/>
</intent-filter>
<!-- HTTPS App Links for invite and promo codes (verified against
Digital Asset Links). autoVerify checks every host in this
filter: a missing assetlinks.json on any of them fails the
whole filter, so release only lists realunit.app / www.
Trailing slash on pathPrefix is required: "/invite" as a
prefix would also match "/invitee". Exact android:path
covers bare /invite?code=. Custom-scheme filter above still
covers realunit-wallet://invite|promo/{code}. Staging
host lives in debug/profile manifests. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="realunit.app"
android:pathPrefix="/invite/"/>
<data
android:scheme="https"
android:host="realunit.app"
android:pathPrefix="/promo/"/>
<data
android:scheme="https"
android:host="realunit.app"
android:path="/invite"/>
<data
android:scheme="https"
android:host="realunit.app"
android:path="/promo"/>
<data
android:scheme="https"
android:host="www.realunit.app"
android:pathPrefix="/invite/"/>
<data
android:scheme="https"
android:host="www.realunit.app"
android:pathPrefix="/promo/"/>
<data
android:scheme="https"
android:host="www.realunit.app"
android:path="/invite"/>
<data
android:scheme="https"
android:host="www.realunit.app"
android:path="/promo"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
Expand Down
61 changes: 60 additions & 1 deletion android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,64 @@
package swiss.realunit.app

import android.os.Handler
import android.os.Looper
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterFragmentActivity()
class MainActivity : FlutterFragmentActivity() {
private val installReferrerChannel = "swiss.realunit.app/install_referrer"

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, installReferrerChannel)
.setMethodCallHandler { call, result ->
if (call.method != "readInstallReferrer") {
result.notImplemented()
return@setMethodCallHandler
}
readInstallReferrer(result)
}
}

private fun readInstallReferrer(result: MethodChannel.Result) {
val client = InstallReferrerClient.newBuilder(this).build()
val main = Handler(Looper.getMainLooper())
var replied = false

fun reply(value: String?) {
if (replied) return
replied = true
result.success(value)
try {
client.endConnection()
} catch (_: Exception) {
}
}

main.postDelayed({ reply(null) }, 3000)
try {
client.startConnection(
object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode != InstallReferrerClient.InstallReferrerResponse.OK) {
reply(null)
return
}
try {
reply(client.installReferrer.installReferrer)
} catch (_: Exception) {
reply(null)
}
}

override fun onInstallReferrerServiceDisconnected() {}
},
)
} catch (_: Exception) {
reply(null)
}
}
}
25 changes: 25 additions & 0 deletions android/app/src/profile/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,29 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application>
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:pathPrefix="/invite/"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:pathPrefix="/promo/"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:path="/invite"/>
<data
android:scheme="https"
android:host="dev.realunit.app"
android:path="/promo"/>
</intent-filter>
</activity>
</application>
</manifest>
4 changes: 4 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
2 changes: 1 addition & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
Loading
Loading