diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml new file mode 100644 index 000000000..fcea794d0 --- /dev/null +++ b/.github/workflows/native-release.yml @@ -0,0 +1,191 @@ +name: Native apps release + +# Builds the signed store builds of the iOS and Android apps and uploads them: +# the Android App Bundle to a Google Play testing track, the iOS archive to +# App Store Connect (TestFlight). Promotion to production stays a manual step +# in both consoles. +# +# Runs only in liketrek/TREK, only by hand or on a version tag, and only after a +# required reviewer approves the `store` environment, which holds every secret +# (see wiki/Native-Apps.md for the list). Pull requests never reach it. +# +# Both stores get the same numbers: the version from client/package.json +# without a prerelease suffix, and a build number that only grows. A prerelease +# reuses its X.Y.Z with a higher build number and belongs on the internal track +# and in TestFlight only. + +on: + workflow_dispatch: + inputs: + track: + description: Google Play track + type: choice + options: [internal, alpha, beta] + default: internal + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: native-release + cancel-in-progress: false + +jobs: + android: + if: github.repository == 'liketrek/TREK' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + environment: store + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + cache: gradle + + - name: Version and build number + id: version + run: | + echo "version=$(node -p "require('./client/package.json').version.split('-')[0]")" >> "$GITHUB_OUTPUT" + echo "build=$((GITHUB_RUN_NUMBER + 1000))" >> "$GITHUB_OUTPUT" + + - name: Install + run: npm ci --workspace=shared --workspace=client + + - name: Build shared + run: npm run build --workspace=shared + + - name: Build the address screen and sync it into the shell + working-directory: client + run: npm run cap:sync -- android + + - name: Upload key + env: + KEYSTORE_B64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_B64 }} + run: printf '%s' "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/upload.jks" + + - name: Build the signed App Bundle + working-directory: client/android + env: + ORG_GRADLE_PROJECT_trekKeystorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ORG_GRADLE_PROJECT_trekKeyAlias: ${{ secrets.ANDROID_KEY_ALIAS }} + ORG_GRADLE_PROJECT_trekKeyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: >- + ./gradlew bundleRelease --no-daemon + -PtrekVersionName=${{ steps.version.outputs.version }} + -PtrekVersionCode=${{ steps.version.outputs.build }} + -PtrekKeystore="$RUNNER_TEMP/upload.jks" + + - uses: actions/upload-artifact@v6 + with: + name: trek-android-${{ steps.version.outputs.version }}-${{ steps.version.outputs.build }} + path: client/android/app/build/outputs/bundle/release/app-release.aab + retention-days: 30 + + # Play only accepts API uploads once one bundle was uploaded by hand + # (wiki/Native-Apps.md). `draft` until the app has left draft in the console. + - name: Upload to Google Play + uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.liketrek.trek + releaseFiles: client/android/app/build/outputs/bundle/release/app-release.aab + track: ${{ inputs.track || 'internal' }} + status: draft + + - name: Remove the upload key + if: always() + run: rm -f "$RUNNER_TEMP/upload.jks" + + ios: + if: github.repository == 'liketrek/TREK' + runs-on: macos-15 + timeout-minutes: 60 + environment: store + steps: + - uses: actions/checkout@v6 + + # App Store uploads must be built with Xcode 26; the image's default is older. + - name: Select Xcode 26 + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) + test -n "$XCODE" + sudo xcode-select -s "$XCODE" + xcodebuild -version + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Version and build number + id: version + run: | + echo "version=$(node -p "require('./client/package.json').version.split('-')[0]")" >> "$GITHUB_OUTPUT" + echo "build=$((GITHUB_RUN_NUMBER + 1000))" >> "$GITHUB_OUTPUT" + + - name: Install + run: npm ci --workspace=shared --workspace=client + + - name: Build shared + run: npm run build --workspace=shared + + - name: Build the address screen and sync it into the shell + working-directory: client + run: npm run cap:sync -- ios + + - name: App Store Connect API key + env: + ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }} + run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8" + + - name: Archive + working-directory: client/ios/App + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild archive + -project App.xcodeproj + -scheme App + -configuration Release + -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/TREK.xcarchive" + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" + -allowProvisioningUpdates + -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + # ExportOptions.plist says destination=upload, so this step sends the build + # to App Store Connect, where it shows up in TestFlight after processing. + - name: Export and upload to App Store Connect + working-directory: client/ios/App + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild -exportArchive + -archivePath "$RUNNER_TEMP/TREK.xcarchive" + -exportOptionsPlist ../ExportOptions.plist + -exportPath "$RUNNER_TEMP/export" + -allowProvisioningUpdates + -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + - name: Remove the API key + if: always() + run: rm -f "$RUNNER_TEMP/AuthKey.p8" diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 672da253c..a76946f3e 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -20,6 +20,8 @@ on: - 'client/src/native/serverAddress.ts' - 'client/capacitor.config.ts' - 'client/vite.native.config.js' + - 'client/scripts/check-store-metadata.mjs' + - 'client/fastlane/**' - 'client/package.json' - 'package-lock.json' - 'shared/src/i18n/*/native.ts' @@ -31,6 +33,21 @@ concurrency: cancel-in-progress: true jobs: + # The store texts fit their limits and every listing image has its size and + # no alpha channel (client/fastlane/README.md). Plain Node, no install. + metadata: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Check the store listings + run: node client/scripts/check-store-metadata.mjs + android: runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -58,9 +75,12 @@ jobs: working-directory: client run: npm run cap:sync -- android - - name: Assemble debug APK + # The release build runs R8 with the keep rules in proguard-rules.pro, so a + # rule that goes missing fails here rather than in the store build. It is + # unsigned: the upload key only exists in native-release.yml. + - name: Assemble debug and release APKs working-directory: client/android - run: ./gradlew assembleDebug lintDebug --no-daemon + run: ./gradlew assembleDebug assembleRelease lintDebug --no-daemon - uses: actions/upload-artifact@v6 with: @@ -74,6 +94,17 @@ jobs: steps: - uses: actions/checkout@v6 + # App Store uploads must be built with Xcode 26; the image's default is older. + - name: Select Xcode 26 + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) + test -n "$XCODE" + sudo xcode-select -s "$XCODE" + xcodebuild -version + + - name: Check the property lists + run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc diff --git a/README.md b/README.md index 41b28db06..0464bf72b 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu #### 📱 Mobile and offline -- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar +- **Apps**: TREK Travel Planner for iPhone, iPad and Android connects to any TREK server, and the browser installs TREK as a standalone app with a themed status bar too - **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders - **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network - **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice @@ -300,7 +300,7 @@ See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/READM

Install as App (PWA)

-TREK works as a Progressive Web App — no App Store needed. +TREK works as a Progressive Web App, and the TREK Travel Planner app for iPhone, iPad and Android connects to the same server ([Native Apps](https://github.com/liketrek/TREK/wiki/Native-Apps)). To install the web app instead: 1. Open TREK in the browser (HTTPS required) 2. **iOS**: Share ▸ *Add to Home Screen* diff --git a/client/android/.gitignore b/client/android/.gitignore index 511927dea..e3aa6c5ba 100644 --- a/client/android/.gitignore +++ b/client/android/.gitignore @@ -52,21 +52,22 @@ captures/ # Comment next line if keeping position of elements in Navigation Editor is relevant for you .idea/navEditor.xml -# Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +# Keystore files and store credentials. The upload key and the Play service +# account live only in the release workflow's secrets and in the owner's +# offline backup, never in the repository. +*.jks +*.keystore +keystore.properties +*service-account*.json # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild .cxx/ # Google Services (e.g. APIs or Firebase) -# Deliberately ignored: this file pins the build to one Firebase project. -# Committing it would make every fork's builds register against the upstream -# FCM project, and rotating the project would become a repo change. Official -# builds inject it from a CI secret; see google-services.json.example. -# app/build.gradle already tolerates its absence (push is simply inert). +# Push is not wired up and the build does not apply the google-services plugin, +# so no CI secret is needed. Kept ignored so a stray Firebase config never lands +# in the repository. google-services.json app/google-services.json diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index 969a2a6c0..38c23a189 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -7,8 +7,11 @@ android { applicationId "com.liketrek.trek" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + // The release workflow (.github/workflows/native-release.yml) passes both: + // the version from client/package.json and a build number that only grows. + // Local and PR builds fall back to these placeholders. + versionCode((project.findProperty('trekVersionCode') ?: '1') as Integer) + versionName(project.findProperty('trekVersionName') ?: '0.0.0-dev') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -16,10 +19,27 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + // The upload key only exists in the release workflow, which decodes it from a + // secret and passes its path and passwords as Gradle properties. Every other + // build (local, fork, PR) stays unsigned and needs no secret. + signingConfigs { + release { + if (project.hasProperty('trekKeystore')) { + storeFile file(project.property('trekKeystore')) + storePassword project.property('trekKeystorePassword') + keyAlias project.property('trekKeyAlias') + keyPassword project.property('trekKeyPassword') + } + } + } buildTypes { release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (project.hasProperty('trekKeystore')) { + signingConfig signingConfigs.release + } + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } } @@ -44,12 +64,3 @@ dependencies { } apply from: 'capacitor.build.gradle' - -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") -} diff --git a/client/android/app/proguard-rules.pro b/client/android/app/proguard-rules.pro index f1b424510..79e4063a5 100644 --- a/client/android/app/proguard-rules.pro +++ b/client/android/app/proguard-rules.pro @@ -19,3 +19,20 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +# TREK's own classes: TrekShellPlugin is reached from JavaScript by name and its +# methods through reflection, so R8 must neither rename nor drop them. +-keep class com.liketrek.trek.** { *; } + +# Every Capacitor plugin: the bridge finds plugins by their annotation and calls +# the annotated methods reflectively. +-keep @com.getcapacitor.annotation.CapacitorPlugin public class * { + @com.getcapacitor.PluginMethod public ; +} + +# The WebView bridge: Capacitor's message handler is called from JavaScript +# through @JavascriptInterface, and every plugin class is instantiated by name. +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} +-keep public class * extends com.getcapacitor.Plugin diff --git a/client/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/client/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java deleted file mode 100644 index f2c2217ef..000000000 --- a/client/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.getcapacitor.myapp; - -import static org.junit.Assert.*; - -import android.content.Context; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - - @Test - public void useAppContext() throws Exception { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - assertEquals("com.getcapacitor.app", appContext.getPackageName()); - } -} diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 2bc68148b..ec0ca5e2b 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - - diff --git a/client/android/app/src/main/res/drawable-xhdpi/notification_icon.png b/client/android/app/src/main/res/drawable-xhdpi/notification_icon.png deleted file mode 100644 index 9e5e54242..000000000 Binary files a/client/android/app/src/main/res/drawable-xhdpi/notification_icon.png and /dev/null differ diff --git a/client/android/app/src/main/res/drawable-xxhdpi/notification_icon.png b/client/android/app/src/main/res/drawable-xxhdpi/notification_icon.png deleted file mode 100644 index b4240d166..000000000 Binary files a/client/android/app/src/main/res/drawable-xxhdpi/notification_icon.png and /dev/null differ diff --git a/client/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png b/client/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png deleted file mode 100644 index 1a36bfff7..000000000 Binary files a/client/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png and /dev/null differ diff --git a/client/android/app/src/main/res/drawable/ic_launcher_background.xml b/client/android/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index d5fccc538..000000000 --- a/client/android/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 0aa82aa1b..9fa1d84f9 100644 --- a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -6,4 +6,7 @@ + + + \ No newline at end of file diff --git a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 0aa82aa1b..9fa1d84f9 100644 --- a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -6,4 +6,7 @@ + + + \ No newline at end of file diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png index 47e9be622..9eda142ec 100644 Binary files a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 9b8ff8815..4c4f0d080 100644 Binary files a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png index 66a1732ef..156955491 100644 Binary files a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png and b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png index 0a584ad35..d703351f9 100644 Binary files a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png index 973f77a5e..590b65af8 100644 Binary files a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 428687e12..380f393ba 100644 Binary files a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png index 22a1733dc..c10f64659 100644 Binary files a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 763f248d2..1d654c1a3 100644 Binary files a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png index e4ad83b1b..059ab588a 100644 Binary files a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index e4768d12b..bc3061c86 100644 Binary files a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png index 20bbdf1d6..92f23a500 100644 Binary files a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index ad270eb56..83d5921ac 100644 Binary files a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/xml/file_paths.xml b/client/android/app/src/main/res/xml/file_paths.xml index bd0c4d80d..63d8bf279 100644 --- a/client/android/app/src/main/res/xml/file_paths.xml +++ b/client/android/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/client/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/client/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java deleted file mode 100644 index 029732784..000000000 --- a/client/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.getcapacitor.myapp; - -import static org.junit.Assert.*; - -import org.junit.Test; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - - @Test - public void addition_isCorrect() throws Exception { - assertEquals(4, 2 + 2); - } -} diff --git a/client/android/build.gradle b/client/android/build.gradle index f8f0e43b6..4f622dca6 100644 --- a/client/android/build.gradle +++ b/client/android/build.gradle @@ -8,7 +8,6 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.13.0' - classpath 'com.google.gms:google-services:4.4.4' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/client/e2e/store/capture.store.ts b/client/e2e/store/capture.store.ts new file mode 100644 index 000000000..7e092ca23 --- /dev/null +++ b/client/e2e/store/capture.store.ts @@ -0,0 +1,72 @@ +import { test, type Page } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; + +import { E2E_STATE_FILE } from '../../playwright.config'; +import { clearNotices, seed } from '../screenshots/shot'; + +/** + * The store screenshots (`npm run store:shots`, then `npm run store:promote`). + * + * The same seeded demo trip the wiki pictures show, captured at the sizes App + * Store Connect and Google Play ask for: the iPhone 6.9" and iPad 13" displays, + * and a Play phone, 7" and 10" tablet. Below 768 px the phone shell answers, so + * the phone pictures show the phone UI; the tablets get the desktop layout they + * get on a real device. + * + * Raw captures go to e2e/.tmp/store//; scripts/store-screenshots.mjs + * flattens them (the stores refuse an alpha channel) and copies them into + * fastlane/. Nothing here asserts behaviour, and the run is not part of CI. + */ + +const STORE_OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'store'); + +const DEVICES = [ + { name: 'iphone69', viewport: { width: 440, height: 956 }, deviceScaleFactor: 3, isMobile: true }, + { name: 'ipad13', viewport: { width: 1032, height: 1376 }, deviceScaleFactor: 2, isMobile: true }, + { name: 'play-phone', viewport: { width: 360, height: 640 }, deviceScaleFactor: 3, isMobile: true }, + { name: 'play-7in', viewport: { width: 810, height: 1440 }, deviceScaleFactor: 2, isMobile: true }, + { name: 'play-10in', viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1.5, isMobile: true }, +] as const; + +/** The screens, in the order the listings show them. */ +const SCREENS: { name: string; path: () => string }[] = [ + { name: 'dashboard', path: () => '/dashboard' }, + { name: 'plan', path: () => `/trips/${seed.tripId}?tab=plan` }, + { name: 'bookings', path: () => `/trips/${seed.tripId}?tab=buchungen` }, + { name: 'costs', path: () => `/trips/${seed.tripId}?tab=finanzplan` }, + { name: 'lists', path: () => `/trips/${seed.tripId}?tab=listen` }, + { name: 'journal', path: () => (seed.journeyId ? `/journey/${seed.journeyId}` : '/journey') }, +]; + +/** Waits until the page has drawn what it is going to draw: fonts, images, no pending fade. */ +async function settle(page: Page): Promise { + await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined); + await page.evaluate(async () => { + await document.fonts.ready; + }); + await page.waitForTimeout(800); +} + +for (const device of DEVICES) { + test.describe(device.name, () => { + test.use({ + storageState: E2E_STATE_FILE, + viewport: device.viewport, + deviceScaleFactor: device.deviceScaleFactor, + isMobile: device.isMobile, + hasTouch: true, + }); + + SCREENS.forEach((screen, index) => { + test(`${index + 1} ${screen.name}`, async ({ page }) => { + const dir = path.join(STORE_OUT_DIR, device.name); + mkdirSync(dir, { recursive: true }); + await page.goto(screen.path()); + await clearNotices(page); + await settle(page); + await page.screenshot({ path: path.join(dir, `${index + 1}_${screen.name}.png`) }); + }); + }); + }); +} diff --git a/client/fastlane/README.md b/client/fastlane/README.md new file mode 100644 index 000000000..08edb1a5e --- /dev/null +++ b/client/fastlane/README.md @@ -0,0 +1,55 @@ +# Store listings + +The texts, review notes, policy drafts and images for the App Store and Google +Play listings of **TREK Travel Planner** (`com.liketrek.trek`). The folder +layout is the one fastlane's `deliver` (App Store) and `supply` (Google Play) +read, so the listings can be uploaded from here; the builds themselves come from +`.github/workflows/native-release.yml`. + +| Path | What | +| --- | --- | +| `metadata/en-US/` | App Store texts: name, subtitle, keywords, description, URLs, release notes | +| `metadata/review_information/` | App Review notes and contact. Placeholders only: the review server, accounts and passwords are entered in App Store Connect, never committed | +| `metadata/android/en-US/` | Google Play texts, the changelog, and `images/` (feature graphic, icon, screenshots) | +| `metadata/privacy-policy.md`, `metadata/delete-account.md` | Drafts of the pages published at https://liketrek.com/privacy and https://liketrek.com/delete-account | +| `screenshots/en-US/` | App Store screenshots, iPhone 6.9" and iPad 13" | + +`npm run store:check` (in `client/`) checks every text against its store limit +and every image for its size and for an alpha channel, which both stores refuse. +CI runs it in the `metadata` job of `native.yml`. + +## Screenshots + +The screenshots are not committed yet: they are captured from a running TREK +with the seeded demo trip, which is a Playwright run and does not run in CI. +To make them, from `client/`: + +1. `npx playwright install chromium` (once). +2. `npm run store:shots` starts its own backend on a throwaway database and the + Vite dev server, seeds the demo trip (the same one the wiki pictures show) + and captures six screens at five device sizes into `e2e/.tmp/store/`. +3. `npm run store:promote` checks each capture's size, flattens it onto the app + background (no alpha) and copies it into `screenshots/en-US/` (iPhone 6.9" + 1320x2868, iPad 13" 2064x2752) and + `metadata/android/en-US/images/{phone,sevenInch,tenInch}Screenshots/` + (1080x1920, 1620x2880, 1920x1080). +4. `npm run store:graphic` rebuilds the feature graphic, now with a crop of the + first phone screenshot, and the 512 px icon. +5. `npm run store:check`, look through the pictures, commit. + +The capture lives in `e2e/store/capture.store.ts`; the device sizes and screens +are listed at its top. + +## Uploading the listing + +With fastlane installed and the App Store Connect API key and Play service +account at hand (the same ones the release workflow uses, see +`wiki/Native-Apps.md`), from `client/`: + +```bash +fastlane deliver --skip_binary_upload --metadata_path fastlane/metadata --screenshots_path fastlane/screenshots +fastlane supply --skip_upload_apk --skip_upload_aab --metadata_path fastlane/metadata/android --package_name com.liketrek.trek +``` + +Both also work from the consoles by hand; the files here are the source of +truth for what the listings say. diff --git a/client/fastlane/metadata/android/en-US/changelogs/default.txt b/client/fastlane/metadata/android/en-US/changelogs/default.txt new file mode 100644 index 000000000..fd85f3c68 --- /dev/null +++ b/client/fastlane/metadata/android/en-US/changelogs/default.txt @@ -0,0 +1 @@ +The first release of TREK for Android: connect to your TREK server, plan together, and take your trips offline. diff --git a/client/fastlane/metadata/android/en-US/full_description.txt b/client/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 000000000..bd7c62d99 --- /dev/null +++ b/client/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,24 @@ +TREK is a travel planner for the trips you take with other people. Plan the days together, see every place on the map, keep bookings, costs and packing lists in one place, and still have all of it with you when the connection drops. + +TREK is open source and works with a TREK server: one you run yourself, at home or on a rented server, one your organisation provides, or the public demo to try it out. Your trips stay on that server. The makers of TREK receive none of your data. + +PLAN TOGETHER +• Days with places, notes, transports and stays, in order and on the map +• Changes from everyone on the trip appear live +• Roles that decide who may change what + +BOOKINGS AND COSTS +• Flights, trains, hotels, rentals and tickets, with their documents +• Shared expenses with fair splits and who owes whom, in any currency + +ON THE ROAD +• Trips you choose are kept on the device, maps included, for when you are offline +• Packing lists and to-dos for the whole group +• A travel journal with photos, and an atlas of the countries you have visited + +YOUR DATA +• No account with us, no tracking, no ads +• Sign in with a password, a passkey or your organisation's single sign-on +• Available in 27 languages + +To use the app you need the address of a TREK server. Without one yet, tap "Try the demo" on the first screen, or read how to run your own at github.com/liketrek/TREK. diff --git a/client/fastlane/metadata/android/en-US/images/featureGraphic.png b/client/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 000000000..ae4f53f4d Binary files /dev/null and b/client/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/client/fastlane/metadata/android/en-US/images/icon.png b/client/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 000000000..8ed9a14ef Binary files /dev/null and b/client/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/client/fastlane/metadata/android/en-US/short_description.txt b/client/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 000000000..3426da766 --- /dev/null +++ b/client/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Plan trips together on your own TREK server, with maps, bookings and costs. diff --git a/client/fastlane/metadata/android/en-US/title.txt b/client/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 000000000..208f34555 --- /dev/null +++ b/client/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +TREK Travel Planner diff --git a/client/fastlane/metadata/copyright.txt b/client/fastlane/metadata/copyright.txt new file mode 100644 index 000000000..7125b0aa7 --- /dev/null +++ b/client/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ +2026 [PUBLISHER LEGAL NAME] diff --git a/client/fastlane/metadata/delete-account.md b/client/fastlane/metadata/delete-account.md new file mode 100644 index 000000000..ec93cd957 --- /dev/null +++ b/client/fastlane/metadata/delete-account.md @@ -0,0 +1,29 @@ +# TREK Travel Planner: Delete your account + +_Draft. The publisher fills in the bracketed fields before this is published at https://liketrek.com/delete-account._ + +Your TREK account and everything in it live on the TREK server you signed in to, not with [PUBLISHER LEGAL NAME], the publisher of the app. You can delete it in either of these ways: + +## In the app + +1. Open the app and sign in. +2. Go to **Settings > Account**. +3. Choose **Delete account** and confirm. + +The account is deleted on the server at once, together with its trips you own, your files, photos, journal entries and settings. Trips you share with others stay with their other members; your contributions to them are removed or anonymised. + +## Without the app + +Open your TREK server in a web browser, sign in and go to **Settings > Account > Delete account**. If you cannot sign in any more, ask the administrator of your server to delete the account. + +## The public demo + +Accounts on demo.liketrek.com cannot be deleted one by one: the demo is shared and is reset to its sample data every hour, which removes everything entered there. + +## Data on your device + +Removing the app from your phone or tablet removes the server address, your session and the trips kept offline. + +## Questions + +[CONTACT EMAIL] diff --git a/client/fastlane/metadata/en-US/description.txt b/client/fastlane/metadata/en-US/description.txt new file mode 100644 index 000000000..bd7c62d99 --- /dev/null +++ b/client/fastlane/metadata/en-US/description.txt @@ -0,0 +1,24 @@ +TREK is a travel planner for the trips you take with other people. Plan the days together, see every place on the map, keep bookings, costs and packing lists in one place, and still have all of it with you when the connection drops. + +TREK is open source and works with a TREK server: one you run yourself, at home or on a rented server, one your organisation provides, or the public demo to try it out. Your trips stay on that server. The makers of TREK receive none of your data. + +PLAN TOGETHER +• Days with places, notes, transports and stays, in order and on the map +• Changes from everyone on the trip appear live +• Roles that decide who may change what + +BOOKINGS AND COSTS +• Flights, trains, hotels, rentals and tickets, with their documents +• Shared expenses with fair splits and who owes whom, in any currency + +ON THE ROAD +• Trips you choose are kept on the device, maps included, for when you are offline +• Packing lists and to-dos for the whole group +• A travel journal with photos, and an atlas of the countries you have visited + +YOUR DATA +• No account with us, no tracking, no ads +• Sign in with a password, a passkey or your organisation's single sign-on +• Available in 27 languages + +To use the app you need the address of a TREK server. Without one yet, tap "Try the demo" on the first screen, or read how to run your own at github.com/liketrek/TREK. diff --git a/client/fastlane/metadata/en-US/keywords.txt b/client/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 000000000..97a72ad9e --- /dev/null +++ b/client/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +travel,trip planner,itinerary,road trip,packing list,travel journal,offline map,budget,vacation diff --git a/client/fastlane/metadata/en-US/marketing_url.txt b/client/fastlane/metadata/en-US/marketing_url.txt new file mode 100644 index 000000000..b9041b3d3 --- /dev/null +++ b/client/fastlane/metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://liketrek.com diff --git a/client/fastlane/metadata/en-US/name.txt b/client/fastlane/metadata/en-US/name.txt new file mode 100644 index 000000000..208f34555 --- /dev/null +++ b/client/fastlane/metadata/en-US/name.txt @@ -0,0 +1 @@ +TREK Travel Planner diff --git a/client/fastlane/metadata/en-US/privacy_url.txt b/client/fastlane/metadata/en-US/privacy_url.txt new file mode 100644 index 000000000..248586c57 --- /dev/null +++ b/client/fastlane/metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://liketrek.com/privacy diff --git a/client/fastlane/metadata/en-US/promotional_text.txt b/client/fastlane/metadata/en-US/promotional_text.txt new file mode 100644 index 000000000..6209d274a --- /dev/null +++ b/client/fastlane/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Plan every day of a trip with the people you travel with, keep bookings and costs in one place, and take it all offline. Your trips stay on the TREK server you choose. diff --git a/client/fastlane/metadata/en-US/release_notes.txt b/client/fastlane/metadata/en-US/release_notes.txt new file mode 100644 index 000000000..d3f4542c7 --- /dev/null +++ b/client/fastlane/metadata/en-US/release_notes.txt @@ -0,0 +1 @@ +The first release of TREK for iPhone and iPad: connect to your TREK server, plan together, and take your trips offline. diff --git a/client/fastlane/metadata/en-US/subtitle.txt b/client/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 000000000..5cacad5c2 --- /dev/null +++ b/client/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +Plan trips on your own server diff --git a/client/fastlane/metadata/en-US/support_url.txt b/client/fastlane/metadata/en-US/support_url.txt new file mode 100644 index 000000000..f5b94c1c2 --- /dev/null +++ b/client/fastlane/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://liketrek.com/support diff --git a/client/fastlane/metadata/primary_category.txt b/client/fastlane/metadata/primary_category.txt new file mode 100644 index 000000000..bed7befd5 --- /dev/null +++ b/client/fastlane/metadata/primary_category.txt @@ -0,0 +1 @@ +TRAVEL diff --git a/client/fastlane/metadata/privacy-policy.md b/client/fastlane/metadata/privacy-policy.md new file mode 100644 index 000000000..2ff1bace5 --- /dev/null +++ b/client/fastlane/metadata/privacy-policy.md @@ -0,0 +1,44 @@ +# TREK Travel Planner: Privacy Policy + +_Draft. The publisher fills in the bracketed fields before this is published at https://liketrek.com/privacy._ + +Last updated: [DATE] + +## Who publishes the app + +TREK Travel Planner ("the app") is published by [PUBLISHER LEGAL NAME], [POSTAL ADDRESS], [CONTACT EMAIL] ("we"). + +## The short version + +The app is a client for a TREK server that you choose. Everything you enter in the app goes to that server and is stored there. We, the publisher of the app, receive none of it: no account, no analytics, no crash reports, no advertising identifiers, no tracking. + +## Where your data goes + +- **Your TREK server.** When you connect the app to a server, the app sends what you enter (your account details, trips, places, bookings, documents, photos, expenses, messages, journal entries and, if you allow it, your location for the map) to that server. Whoever runs that server is responsible for it: you, if you host it yourself, or the organisation that provides it. Their own privacy policy applies to it. +- **The public demo.** If you choose "Try the demo", the app connects to demo.liketrek.com, which we run. The demo is shared by everyone who tries it and is reset every hour; do not enter real personal data there. +- **Map tiles and exchange rates.** To draw maps, the app loads map tiles from the tile provider your server is configured with (by default OpenStreetMap-based providers such as OpenFreeMap). These requests carry the map area you look at and your IP address, as every web request does. Currency conversion rates come from a public exchange-rate service. These providers receive no account or trip data. +- **The system browser.** Signing in with single sign-on or a passkey opens the system browser on your server's sign-in page. + +## Data on your device + +The app keeps the server address, your session cookie and, for trips you choose to keep offline, a copy of those trips, their files and map tiles on your device. Removing the app removes all of it. On Android the app is excluded from device backups, so your session does not travel to other devices. + +## Permissions + +The camera, microphone and photo library are used only when you add a photo, a video or a document. Location is used only to centre the map on you. Each is asked for by the system when first needed and can be withdrawn in the system settings. + +## Deleting your data + +Delete your account in the app under Settings > Account > Delete account, or ask the administrator of your server. This removes the account and its data from that server. See https://liketrek.com/delete-account. Because we do not hold your data, we cannot delete it for you, but we will help you find the right contact. + +## Children + +The app is not directed at children under 13, and we do not knowingly receive data from them. + +## Changes + +We will publish changes to this policy on this page and update the date above. + +## Contact + +[CONTACT EMAIL] diff --git a/client/fastlane/metadata/review_information/demo_password.txt b/client/fastlane/metadata/review_information/demo_password.txt new file mode 100644 index 000000000..d8496d9b5 --- /dev/null +++ b/client/fastlane/metadata/review_information/demo_password.txt @@ -0,0 +1 @@ +[PASSWORD, never committed] diff --git a/client/fastlane/metadata/review_information/demo_user.txt b/client/fastlane/metadata/review_information/demo_user.txt new file mode 100644 index 000000000..0fc528787 --- /dev/null +++ b/client/fastlane/metadata/review_information/demo_user.txt @@ -0,0 +1 @@ +[REVIEW ACCOUNT 1 EMAIL] diff --git a/client/fastlane/metadata/review_information/email_address.txt b/client/fastlane/metadata/review_information/email_address.txt new file mode 100644 index 000000000..40b688978 --- /dev/null +++ b/client/fastlane/metadata/review_information/email_address.txt @@ -0,0 +1 @@ +[CONTACT EMAIL] diff --git a/client/fastlane/metadata/review_information/first_name.txt b/client/fastlane/metadata/review_information/first_name.txt new file mode 100644 index 000000000..0f98f5681 --- /dev/null +++ b/client/fastlane/metadata/review_information/first_name.txt @@ -0,0 +1 @@ +[CONTACT FIRST NAME] diff --git a/client/fastlane/metadata/review_information/last_name.txt b/client/fastlane/metadata/review_information/last_name.txt new file mode 100644 index 000000000..7d1cc9cff --- /dev/null +++ b/client/fastlane/metadata/review_information/last_name.txt @@ -0,0 +1 @@ +[CONTACT LAST NAME] diff --git a/client/fastlane/metadata/review_information/notes.txt b/client/fastlane/metadata/review_information/notes.txt new file mode 100644 index 000000000..e8bd67d45 --- /dev/null +++ b/client/fastlane/metadata/review_information/notes.txt @@ -0,0 +1,33 @@ +TREK Travel Planner is the iOS client of TREK, an open-source, self-hostable travel planner (https://github.com/liketrek/TREK). Each user or organisation runs their own TREK server; the app asks for the server's address on first start and then shows the TREK web interface that server delivers. + +HOW TO REVIEW +1. Open the app. On the address screen enter: [REVIEW SERVER URL, e.g. https://review.example.com] +2. Sign in with one of the review accounts below (each starts with the same sample trips). +3. Open a sample trip from the dashboard: the day plan with its map, then the Bookings, Costs and Lists tabs. +4. Settings > Account shows the connected server and the privacy policy link at the top, and "Delete account" with the profile. + +Review accounts on the review server (not the public demo, which blocks deletion): +- [REVIEW ACCOUNT 1 EMAIL] / [PASSWORD] +- [REVIEW ACCOUNT 2 EMAIL] / [PASSWORD] +- [REVIEW ACCOUNT 3 EMAIL] / [PASSWORD] + +NATIVE FEATURES +The app is more than a website in a frame: its own address screen with server validation and an offline fallback screen, sign-in through the system browser (ASWebAuthenticationSession) for single sign-on and passkeys, offline maps and trips stored on the device, the share sheet for exports and documents, haptic feedback, the native status bar, splash screen and back gestures. + +DOWNLOADED INTERFACE (2.5.2 / 4.2) +The interface comes from the user's own server, in the version that server runs, just as a mail client shows the messages its server holds. It contains no executable code that changes the app's features or purpose beyond what this build reviews; every server runs the same open-source TREK. + +APP TRANSPORT SECURITY +NSAllowsArbitraryLoads is set because the user types in their own server address, which the app cannot know at build time: often a home server reached as http://192.168.x.x or over a VPN such as Tailscale (100.64.0.0/10, not covered by NSAllowsLocalNetworking). HTTPS remains the documented recommendation for servers outside a local network. + +SIGN-IN (4.8) +Accounts belong to the user's server, not to us. The app offers whatever that server offers: a password, a passkey, or the single sign-on of the organisation running the server. There is no third-party social login, so Sign in with Apple does not apply. + +USER-GENERATED CONTENT +Content is shared only between people invited to the same trip on the same private server; there is no public feed. The server's administrator manages accounts and can remove content and users. + +ACCOUNT DELETION +Settings > Account > Delete account deletes the account and its data on the server. See also https://liketrek.com/delete-account. + +CONTACT +[CONTACT NAME], [CONTACT EMAIL] diff --git a/client/fastlane/metadata/review_information/phone_number.txt b/client/fastlane/metadata/review_information/phone_number.txt new file mode 100644 index 000000000..44625e806 --- /dev/null +++ b/client/fastlane/metadata/review_information/phone_number.txt @@ -0,0 +1 @@ +[CONTACT PHONE] diff --git a/client/fastlane/metadata/secondary_category.txt b/client/fastlane/metadata/secondary_category.txt new file mode 100644 index 000000000..cd65e7934 --- /dev/null +++ b/client/fastlane/metadata/secondary_category.txt @@ -0,0 +1 @@ +PRODUCTIVITY diff --git a/client/ios/App/App/AppDelegate.swift b/client/ios/App/App/AppDelegate.swift index cc7ffaae1..fec95acae 100644 --- a/client/ios/App/App/AppDelegate.swift +++ b/client/ios/App/App/AppDelegate.swift @@ -33,22 +33,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - // MARK: - Push notifications - // - // @capacitor/push-notifications does not swizzle the APNs callbacks; it listens - // on NotificationCenter instead, so these two forwarders are mandatory. Without - // them PushNotifications.register() resolves and then nothing ever arrives — - // neither the `registration` nor the `registrationError` listener fires, which - // makes it look like a server problem rather than a missing 6 lines here. - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) - } - - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) - } - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { diff --git a/client/ios/App/App/Info.plist b/client/ios/App/App/Info.plist index 5e6e9cbef..b4d095119 100644 --- a/client/ios/App/App/Info.plist +++ b/client/ios/App/App/Info.plist @@ -22,6 +22,10 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSCameraUsageDescription TREK uses the camera so you can photograph a place, a journey entry or a booking document and add it to your trip. + + NSMicrophoneUsageDescription + TREK records sound when you film a video for a journey entry. NSPhotoLibraryUsageDescription TREK needs access to your photos so you can add them to trips, places and journey entries. diff --git a/client/ios/ExportOptions.plist b/client/ios/ExportOptions.plist new file mode 100644 index 000000000..5dc50541e --- /dev/null +++ b/client/ios/ExportOptions.plist @@ -0,0 +1,20 @@ + + + + + + method + app-store-connect + destination + upload + signingStyle + automatic + manageAppVersionAndBuildNumber + + uploadSymbols + + + diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index ad1b49e51..e408a38cd 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -1,7 +1,8 @@ import { CapacitorHttp } from '@capacitor/core' import { SplashScreen } from '@capacitor/splash-screen' import { TrekShell } from '../src/native/trekShell' -import { probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress' +import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress' +import { PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links' import { pickLocale, translator } from './i18n' import logoUrl from '../public/icons/icon-white.svg' import './shell.css' @@ -62,6 +63,11 @@ function header(title: string, hint: string) { ] } +/** A page outside the app; the WebView hands it to the system browser. */ +function externalLink(text: string, href: string) { + return el('a', { href, target: '_blank', rel: 'noopener noreferrer', className: 'shell-link' }, text) +} + function showConnect(prefill = '') { const input = el('input', { type: 'url', @@ -97,7 +103,32 @@ function showConnect(prefill = '') { input.focus() }) - root.replaceChildren(...header(t('native.connect.title'), t('native.connect.hint')), form) + // For someone without a server of their own yet: the public demo, through the + // same probe, so an outdated or unreachable demo says so like any server. + const demo = el('button', { type: 'button', className: 'shell-link' }, t('native.connect.tryDemo')) + demo.addEventListener('click', async () => { + error.textContent = '' + demo.disabled = true + const result = await probeDemo(get) + if (result.ok) { + await TrekShell.setServer({ url: result.url }) + return + } + error.textContent = failureMessage(result.reason) + demo.disabled = false + }) + + root.replaceChildren( + ...header(t('native.connect.title'), t('native.connect.hint')), + form, + demo, + el( + 'div', + { className: 'shell-links' }, + externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL), + externalLink(t('native.privacy'), PRIVACY_URL), + ), + ) } function showUnreachable(server: string) { @@ -112,6 +143,7 @@ function showUnreachable(server: string) { root.replaceChildren( ...header(t('native.offline.title'), t('native.offline.hint', { server: new URL(server).host })), el('div', { className: 'shell-form' }, retry, change), + el('div', { className: 'shell-links' }, externalLink(t('native.privacy'), PRIVACY_URL)), ) } diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css index 0b3b92173..4135d1a95 100644 --- a/client/native-shell/shell.css +++ b/client/native-shell/shell.css @@ -87,4 +87,13 @@ html, body { color: var(--muted); } +.shell-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px 16px; + font-size: 0.875rem; +} +a.shell-link { text-decoration: underline; text-underline-offset: 2px; } + .shell-error { color: var(--danger); margin: 0; min-height: 1.5em; line-height: 1.5; } diff --git a/client/package.json b/client/package.json index 894cda2cf..4dbb5ee4a 100644 --- a/client/package.json +++ b/client/package.json @@ -10,9 +10,13 @@ "dev": "vite", "prebuild": "node scripts/generate-icons.mjs", "icons:native": "node scripts/generate-native-icons.mjs", - "icons:app": "node scripts/generate-app-icons.mjs && npx @capacitor/assets generate --android --ios --iconBackgroundColor #09090b --iconBackgroundColorDark #09090b --splashBackgroundColor #09090b --splashBackgroundColorDark #09090b", + "icons:app": "node scripts/generate-app-icons.mjs && npx @capacitor/assets generate --android --ios --iconBackgroundColor #09090b --iconBackgroundColorDark #09090b --splashBackgroundColor #09090b --splashBackgroundColorDark #09090b && node scripts/generate-adaptive-icons.mjs", "build:native": "vite build --config vite.native.config.js", "cap:sync": "npm run build:native && cap sync", + "store:shots": "playwright test --project=store", + "store:promote": "node scripts/store-screenshots.mjs", + "store:graphic": "node scripts/generate-feature-graphic.mjs", + "store:check": "node scripts/check-store-metadata.mjs", "build": "vite build", "build:analyze": "vite build --mode analyze", "preview": "vite preview", diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 0f5303c82..7edc98986 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -94,6 +94,15 @@ export default defineConfig({ }, dependencies: ['shot-fixtures'], }, + // App Store and Google Play screenshots (`npm run store:shots`): the + // seeded demo trip at every device size the listings need. Each device sets + // its own viewport and scale in e2e/store/capture.store.ts. Not part of CI. + { + name: 'store', + testMatch: /\.store\.ts/, + use: { ...devices['Desktop Chrome'], storageState: E2E_STATE_FILE }, + dependencies: ['shot-fixtures'], + }, // Help-center media (`npm run help:media`): pictures and walkthroughs for // the in-app guides, generated from the guide definitions in src/help/. // Same seed as the wiki screenshots; its own match so neither run pays diff --git a/client/scripts/check-store-metadata.mjs b/client/scripts/check-store-metadata.mjs new file mode 100644 index 000000000..26818f490 --- /dev/null +++ b/client/scripts/check-store-metadata.mjs @@ -0,0 +1,102 @@ +/** + * Checks the store listings in fastlane/ before anything is uploaded + * (`npm run store:check`, and the metadata job in .github/workflows/native.yml): + * + * - every text both stores require exists and fits its limit (App Store: name and + * subtitle 30, keywords 100, promotional text 170, description 4000; Google + * Play: title 30, short description 80, full description 4000, changelog 500); + * - the URL files hold one https URL each; + * - every screenshot and listing graphic that is present has the exact size its + * slot takes and no alpha channel, which both stores refuse. + * + * Plain Node, no dependencies: a PNG's size and colour type are in its header. + */ +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const fastlane = join(dirname(fileURLToPath(import.meta.url)), '..', 'fastlane'); +const meta = join(fastlane, 'metadata'); +const problems = []; + +/** [file, max length] the listing needs. */ +const TEXTS = [ + ['en-US/name.txt', 30], + ['en-US/subtitle.txt', 30], + ['en-US/keywords.txt', 100], + ['en-US/promotional_text.txt', 170], + ['en-US/description.txt', 4000], + ['en-US/release_notes.txt', 4000], + ['android/en-US/title.txt', 30], + ['android/en-US/short_description.txt', 80], + ['android/en-US/full_description.txt', 4000], + ['android/en-US/changelogs/default.txt', 500], + ['review_information/notes.txt', 4000], +]; +const URLS = ['en-US/support_url.txt', 'en-US/marketing_url.txt', 'en-US/privacy_url.txt']; + +function read(rel) { + const path = join(meta, rel); + if (!existsSync(path)) { + problems.push(`${rel}: missing`); + return null; + } + return readFileSync(path, 'utf8').trim(); +} + +for (const [rel, max] of TEXTS) { + const text = read(rel); + if (text === null) continue; + if (!text) problems.push(`${rel}: empty`); + // Code points, as the stores count them, not UTF-16 units. + const length = [...text].length; + if (length > max) problems.push(`${rel}: ${length} characters, the limit is ${max}`); +} +for (const rel of URLS) { + const url = read(rel); + if (url !== null && !/^https:\/\/\S+$/.test(url)) problems.push(`${rel}: not a single https URL`); +} + +/** Width, height and whether the PNG carries alpha (colour types 4 and 6), from its header. */ +function pngInfo(path) { + const head = readFileSync(path).subarray(0, 26); + if (head.toString('latin1', 1, 4) !== 'PNG') return null; + return { width: head.readUInt32BE(16), height: head.readUInt32BE(20), alpha: head[25] === 4 || head[25] === 6 }; +} + +/** [folder or file, accepted sizes] for every image slot. */ +const IMAGES = [ + [join(fastlane, 'screenshots', 'en-US'), ['1320x2868', '2064x2752']], + [join(meta, 'android', 'en-US', 'images', 'phoneScreenshots'), ['1080x1920']], + [join(meta, 'android', 'en-US', 'images', 'sevenInchScreenshots'), ['1620x2880']], + [join(meta, 'android', 'en-US', 'images', 'tenInchScreenshots'), ['1920x1080']], + [join(meta, 'android', 'en-US', 'images', 'featureGraphic.png'), ['1024x500']], + [join(meta, 'android', 'en-US', 'images', 'icon.png'), ['512x512']], +]; + +for (const [target, sizes] of IMAGES) { + if (!existsSync(target)) continue; + const files = target.endsWith('.png') + ? [target] + : readdirSync(target) + .filter((f) => f.endsWith('.png')) + .map((f) => join(target, f)); + for (const file of files) { + const info = pngInfo(file); + const rel = file.slice(fastlane.length + 1); + if (!info) { + problems.push(`${rel}: not a PNG`); + continue; + } + if (!sizes.includes(`${info.width}x${info.height}`)) { + problems.push(`${rel}: ${info.width}x${info.height}, expected ${sizes.join(' or ')}`); + } + if (info.alpha) problems.push(`${rel}: has an alpha channel`); + } +} + +if (problems.length) { + for (const problem of problems) console.error(`FAIL ${problem}`); + process.exit(1); +} +console.log('store metadata: every text fits, every image has its size and no alpha'); diff --git a/client/scripts/generate-adaptive-icons.mjs b/client/scripts/generate-adaptive-icons.mjs new file mode 100644 index 000000000..c48a2f8c6 --- /dev/null +++ b/client/scripts/generate-adaptive-icons.mjs @@ -0,0 +1,33 @@ +/** + * Writes the Android adaptive icon layers at their full size, after + * `npx @capacitor/assets generate` (see the icons:app script). + * + * The assets tool renders the foreground and background layers at the legacy + * launcher size (192 px at xxxhdpi), while an adaptive icon layer is 108 dp + * (432 px at xxxhdpi), so launchers that draw the icon large showed it soft. + * The layers come from the same sources in assets/, and + * mipmap-anydpi-v26/ic_launcher*.xml keeps placing them as before; it also uses + * the foreground as the monochrome layer for themed icons, since the mark is + * white on transparent. + */ +import { dirname, join } from 'path'; +import sharp from 'sharp'; +import { fileURLToPath } from 'url'; + +const clientDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const resDir = join(clientDir, 'android', 'app', 'src', 'main', 'res'); + +/** 108 dp in pixels for each density bucket. */ +const DENSITIES = { ldpi: 81, mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 }; +const LAYERS = { ic_launcher_foreground: 'icon-foreground.png', ic_launcher_background: 'icon-background.png' }; + +for (const [density, size] of Object.entries(DENSITIES)) { + for (const [name, source] of Object.entries(LAYERS)) { + const out = join(resDir, `mipmap-${density}`, `${name}.png`); + await sharp(join(clientDir, 'assets', source)) + .resize(size, size) + .png({ compressionLevel: 9 }) + .toFile(out); + console.log(`wrote ${out}`); + } +} diff --git a/client/scripts/generate-feature-graphic.mjs b/client/scripts/generate-feature-graphic.mjs new file mode 100644 index 000000000..802bf0886 --- /dev/null +++ b/client/scripts/generate-feature-graphic.mjs @@ -0,0 +1,75 @@ +/** + * The Google Play listing graphics (`npm run store:graphic`): + * + * featureGraphic.png 1024 x 500, the dark app background, the white TREK mark, + * the name and the subtitle, and on the right a crop of the + * first phone screenshot when one has been promoted + * (`npm run store:promote`); without it, mark and text only. + * icon.png 512 x 512, from the same 1024 px tile as the app icon. + * + * Both are flattened: Play refuses an alpha channel in either. + */ +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import sharp from 'sharp'; +import { fileURLToPath } from 'url'; + +const clientDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const imagesDir = join(clientDir, 'fastlane', 'metadata', 'android', 'en-US', 'images'); +mkdirSync(imagesDir, { recursive: true }); + +const WIDTH = 1024; +const HEIGHT = 500; +const BACKGROUND = '#09090b'; +const TITLE = 'TREK Travel Planner'; +const SUBTITLE = readFileSync(join(clientDir, 'fastlane', 'metadata', 'en-US', 'subtitle.txt'), 'utf8').trim(); + +const escapeXml = (text) => text.replace(/&/g, '&').replace(//g, '>'); + +const mark = await sharp(join(clientDir, 'public', 'icons', 'icon-white.svg')) + .resize(120, 120) + .png() + .toBuffer(); + +const text = Buffer.from(` + ${escapeXml(TITLE)} + ${escapeXml(SUBTITLE)} +`); + +const layers = [ + { input: mark, left: 72, top: 110 }, + { input: text, left: 0, top: 0 }, +]; + +// The first phone screenshot, cut to the top of the screen and set at the right edge. +const phone = join(imagesDir, 'phoneScreenshots', '1_dashboard.png'); +if (existsSync(phone)) { + const crop = await sharp(phone) + .resize({ width: 300 }) + .extract({ left: 0, top: 0, width: 300, height: 440 }) + .png() + .toBuffer(); + const frame = Buffer.from(` + `); + const rounded = await sharp(crop) + .composite([{ input: frame, blend: 'dest-in' }]) + .png() + .toBuffer(); + layers.push({ input: rounded, left: WIDTH - 300 - 64, top: 30 }); +} + +await sharp({ create: { width: WIDTH, height: HEIGHT, channels: 3, background: BACKGROUND } }) + .composite(layers) + .flatten({ background: BACKGROUND }) + .removeAlpha() + .png() + .toFile(join(imagesDir, 'featureGraphic.png')); + +await sharp(join(clientDir, 'assets', 'icon-only.png')) + .resize(512, 512) + .flatten({ background: BACKGROUND }) + .removeAlpha() + .png() + .toFile(join(imagesDir, 'icon.png')); + +console.log(`wrote ${join(imagesDir, 'featureGraphic.png')} and icon.png`); diff --git a/client/scripts/store-screenshots.mjs b/client/scripts/store-screenshots.mjs new file mode 100644 index 000000000..d5f6d62e7 --- /dev/null +++ b/client/scripts/store-screenshots.mjs @@ -0,0 +1,64 @@ +/** + * Moves the store screenshots from e2e/.tmp/store/ (written by the Playwright + * `store` project, `npm run store:shots`) into the folders fastlane uploads + * from, flattened onto the app background: both stores refuse an image with an + * alpha channel. + * + * iphone69, ipad13 fastlane/screenshots/en-US/ (deliver, App Store) + * play-phone fastlane/metadata/android/en-US/images/phoneScreenshots/ + * play-7in .../sevenInchScreenshots/ + * play-10in .../tenInchScreenshots/ (supply, Google Play) + * + * Every image is checked against the size its device must have, so a capture at + * the wrong viewport never reaches a listing. `npm run store:check` checks the + * committed files the same way. + */ +import { existsSync, mkdirSync, readdirSync } from 'fs'; +import { dirname, join } from 'path'; +import sharp from 'sharp'; +import { fileURLToPath } from 'url'; + +const clientDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const rawDir = join(clientDir, 'e2e', '.tmp', 'store'); +const fastlane = join(clientDir, 'fastlane'); +const playImages = join(fastlane, 'metadata', 'android', 'en-US', 'images'); + +const TARGETS = { + iphone69: { dir: join(fastlane, 'screenshots', 'en-US'), size: [1320, 2868] }, + ipad13: { dir: join(fastlane, 'screenshots', 'en-US'), size: [2064, 2752] }, + 'play-phone': { dir: join(playImages, 'phoneScreenshots'), size: [1080, 1920] }, + 'play-7in': { dir: join(playImages, 'sevenInchScreenshots'), size: [1620, 2880] }, + 'play-10in': { dir: join(playImages, 'tenInchScreenshots'), size: [1920, 1080] }, +}; + +const BACKGROUND = '#09090b'; + +if (!existsSync(rawDir)) { + console.error(`No captures in ${rawDir}. Run npm run store:shots first.`); + process.exit(1); +} + +let failed = false; +for (const [device, { dir, size }] of Object.entries(TARGETS)) { + const from = join(rawDir, device); + if (!existsSync(from)) { + console.error(`missing captures for ${device}`); + failed = true; + continue; + } + mkdirSync(dir, { recursive: true }); + for (const file of readdirSync(from).filter((f) => f.endsWith('.png'))) { + const image = sharp(join(from, file)); + const { width, height } = await image.metadata(); + if (width !== size[0] || height !== size[1]) { + console.error(`${device}/${file}: ${width}x${height}, expected ${size[0]}x${size[1]}`); + failed = true; + continue; + } + // The App Store folder holds both devices; the prefix keeps them apart. + const name = dir.endsWith(join('screenshots', 'en-US')) ? `${device}_${file}` : file; + await image.flatten({ background: BACKGROUND }).removeAlpha().png().toFile(join(dir, name)); + console.log(`wrote ${join(dir, name)}`); + } +} +process.exit(failed ? 1 : 0); diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index dda191525..ecdb620e1 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import { type ComponentType, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from '../../i18n'; +import { withoutDonationsInApp } from '../../utils/donations'; import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../utils/releaseNotes'; import { fs } from '../shared/DialogShell'; import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit'; @@ -134,7 +135,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b }); }; - const supportLinks: SupportLink[] = [ + const supportLinks: SupportLink[] = withoutDonationsInApp([ { href: 'https://ko-fi.com/mauriceboe', brand: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour @@ -177,7 +178,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b title: 'Wiki', hint: t('settings.about.wikiHint'), }, - ]; + ]); const subtitle = t('admin.github.subtitle').replace('{repo}', REPO); diff --git a/client/src/components/Settings/AboutTab.tsx b/client/src/components/Settings/AboutTab.tsx index 83d071ee9..f3098e4a7 100644 --- a/client/src/components/Settings/AboutTab.tsx +++ b/client/src/components/Settings/AboutTab.tsx @@ -5,6 +5,7 @@ import Section from './Section'; import { StatusPill } from './settingsKit'; import { fs } from '../shared/DialogShell'; import { useAuthStore } from '../../store/authStore'; +import { donationsShown } from '../../utils/donations'; interface Props { appVersion: string; @@ -107,20 +108,25 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement { not the part being trimmed here. */} {!managed && (
- } - title="Ko-fi" - hint={t('admin.github.support')} - /> - } - title="Buy Me a Coffee" - hint={t('admin.github.support')} - /> + {/* Not in the native apps, whose store rules allow no outside payment links. */} + {donationsShown() && ( + <> + } + title="Ko-fi" + hint={t('admin.github.support')} + /> + } + title="Buy Me a Coffee" + hint={t('admin.github.support')} + /> + + )} { + vi.mocked(nativeServerHost).mockReturnValue('trek.example.com'); + render(
); + const link = screen.getByRole('link', { name: 'Privacy policy' }); + expect(link).toHaveAttribute('href', 'https://liketrek.com/privacy'); + expect(link).toHaveAttribute('target', '_blank'); + }); }); describe('the sign-in screen link', () => { diff --git a/client/src/components/Settings/NativeServerSection.tsx b/client/src/components/Settings/NativeServerSection.tsx index 854433bc8..9788c6bd3 100644 --- a/client/src/components/Settings/NativeServerSection.tsx +++ b/client/src/components/Settings/NativeServerSection.tsx @@ -8,7 +8,7 @@ import { useNativeServer } from './useNativeServer'; /** Only inside the iOS/Android app: which server it talks to, and the way to another one. */ export default function NativeServerSection(): React.ReactElement | null { const { t } = useTranslation(); - const { host, changeServer } = useNativeServer(); + const { host, changeServer, privacyUrl } = useNativeServer(); if (!host) return null; return (
@@ -20,6 +20,14 @@ export default function NativeServerSection(): React.ReactElement | null { > {t('native.changeServer')} + + {t('native.privacy')} +
); } diff --git a/client/src/components/Settings/useNativeServer.ts b/client/src/components/Settings/useNativeServer.ts index 36e558999..83814ed2c 100644 --- a/client/src/components/Settings/useNativeServer.ts +++ b/client/src/components/Settings/useNativeServer.ts @@ -1,14 +1,17 @@ +import { PRIVACY_URL } from '../../native/links'; import { nativeServerHost } from '../../native/platform'; import { TrekShell } from '../../native/trekShell'; /** * The server section both settings views show inside the iOS and Android app: * the host the app is connected to (null in a browser, where the section stays - * hidden) and the way back to the address screen. + * hidden), the way back to the address screen, and the privacy policy the store + * listings name, which an app has to reach from inside itself. */ -export function useNativeServer(): { host: string | null; changeServer: () => void } { +export function useNativeServer(): { host: string | null; changeServer: () => void; privacyUrl: string } { return { host: nativeServerHost(), + privacyUrl: PRIVACY_URL, changeServer: () => { void TrekShell.resetServer(); }, diff --git a/client/src/components/SystemNotices/SystemNoticeHost.tsx b/client/src/components/SystemNotices/SystemNoticeHost.tsx index 6399da29c..d6625a998 100644 --- a/client/src/components/SystemNotices/SystemNoticeHost.tsx +++ b/client/src/components/SystemNotices/SystemNoticeHost.tsx @@ -3,6 +3,7 @@ import { useSystemNoticeStore, type SystemNoticeDTO } from '../../store/systemNo import { useTranslation } from '../../i18n/index.js'; import { ModalRenderer } from './SystemNoticeModal.js'; import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js'; +import { donationsShown, noticeAsksForDonation } from '../../utils/donations'; // Mobile breakpoint matches the modal sheet's (max-width: 639px). function useIsMobile() { @@ -76,7 +77,9 @@ export function SystemNoticeHost() { // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile, and a // notice whose copy this build does not have yet waits for the reload (see hasCopy). - const readable = notices.filter(n => hasCopy(n, t)); + // Inside the native apps a notice that asks for a donation never shows: the + // store rules on payments allow no outside payment link there. + const readable = notices.filter(n => hasCopy(n, t) && (donationsShown() || !noticeAsksForDonation(n))); const visible = isMobile ? readable.filter(n => !n.desktopOnly) : readable; const modals = visible.filter(n => n.display === 'modal'); diff --git a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx index a545bac42..3c508b78b 100644 --- a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../../../i18n' import { useGithubReleases } from '../../../components/Admin/useGithubReleases' import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../../utils/releaseNotes' import { MAdminButton, MAdminCard } from './MAdminUi' +import { withoutDonationsInApp } from '../../../utils/donations' const REPO = 'liketrek/TREK' @@ -116,14 +117,14 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea ) - const cards: LinkCard[] = [ + const cards: LinkCard[] = withoutDonationsInApp([ { href: 'https://ko-fi.com/mauriceboe', color: '#ff5e5b', icon: , title: 'Ko-fi', sub: t('admin.github.support') }, { href: 'https://buymeacoffee.com/mauriceboe', color: '#ffdd00', icon: , title: 'Buy Me a Coffee', sub: t('admin.github.support') }, { href: 'https://discord.gg/NhZBDSd4qW', color: '#5865F2', icon: discordIcon, title: 'Discord', sub: 'Join the community' }, { href: 'https://github.com/liketrek/TREK/issues/new?template=bug_report.yml', color: '#ef4444', icon: , title: t('settings.about.reportBug'), sub: t('settings.about.reportBugHint') }, { href: 'https://github.com/liketrek/TREK/discussions/new?category=feature-requests', color: '#f59e0b', icon: , title: t('settings.about.featureRequest'), sub: t('settings.about.featureRequestHint') }, { href: 'https://github.com/liketrek/TREK/wiki', color: '#6366f1', icon: , title: 'Wiki', sub: t('settings.about.wikiHint') }, - ] + ]) return (
diff --git a/client/src/mobile/screens/settings/MSettingsAbout.tsx b/client/src/mobile/screens/settings/MSettingsAbout.tsx index a6f2a983e..8960bb3c0 100644 --- a/client/src/mobile/screens/settings/MSettingsAbout.tsx +++ b/client/src/mobile/screens/settings/MSettingsAbout.tsx @@ -2,6 +2,7 @@ import { Bug, BookOpen, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'luc import type { LucideIcon } from 'lucide-react' import { useTranslation } from '../../../i18n' import { MSetCard } from './MSettingsUi' +import { withoutDonationsInApp } from '../../../utils/donations' interface AboutLink { href: string @@ -14,14 +15,14 @@ interface AboutLink { export default function MSettingsAbout({ appVersion }: { appVersion: string }) { const { t } = useTranslation() - const links: AboutLink[] = [ + const links: AboutLink[] = withoutDonationsInApp([ { href: 'https://ko-fi.com/mauriceboe', icon: Coffee, title: 'Ko-fi', sub: t('admin.github.support') }, { href: 'https://buymeacoffee.com/mauriceboe', icon: Heart, title: 'Buy Me a Coffee', sub: t('admin.github.support') }, { href: 'https://discord.gg/NhZBDSd4qW', icon: Heart, title: 'Discord', sub: 'Join the community' }, { href: 'https://github.com/liketrek/TREK/issues/new?template=bug_report.yml', icon: Bug, title: t('settings.about.reportBug'), sub: t('settings.about.reportBugHint') }, { href: 'https://github.com/liketrek/TREK/discussions/new?category=feature-requests', icon: Lightbulb, title: t('settings.about.featureRequest'), sub: t('settings.about.featureRequestHint') }, { href: 'https://github.com/liketrek/TREK/wiki', icon: BookOpen, title: 'Wiki', sub: t('settings.about.wikiHint') }, - ] + ]) return ( diff --git a/client/src/mobile/screens/settings/MSettingsServer.tsx b/client/src/mobile/screens/settings/MSettingsServer.tsx index 598c28ccb..d7fd34851 100644 --- a/client/src/mobile/screens/settings/MSettingsServer.tsx +++ b/client/src/mobile/screens/settings/MSettingsServer.tsx @@ -7,7 +7,7 @@ import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'; /** Only inside the iOS/Android app: which server it talks to, and the way to another one. */ export default function MSettingsServer() { const { t } = useTranslation(); - const { host, changeServer } = useNativeServer(); + const { host, changeServer, privacyUrl } = useNativeServer(); if (!host) return null; return ( @@ -15,6 +15,14 @@ export default function MSettingsServer() { {t('native.changeServer')} + + {t('native.privacy')} + ); } diff --git a/client/src/native/links.ts b/client/src/native/links.ts new file mode 100644 index 000000000..e70e0b8aa --- /dev/null +++ b/client/src/native/links.ts @@ -0,0 +1,13 @@ +/** + * Pages the apps link to outside the user's server. They open in the system + * browser: the WebView hands every navigation off the server's own origin to it. + */ + +/** The privacy policy the store listings name too. */ +export const PRIVACY_URL = 'https://liketrek.com/privacy'; + +/** What a TREK server is and how to run one, for someone who has none yet. */ +export const SERVER_DOCS_URL = 'https://github.com/liketrek/TREK#readme'; + +/** The public demo the address screen offers to someone without a server. */ +export const DEMO_SERVER_URL = 'https://demo.liketrek.com'; diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index c4a3d3be1..7710c502e 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -106,4 +106,22 @@ describe('startNativeApp', () => { await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.any(Error))); vi.unstubAllGlobals(); }); + + it('stays quiet when the installed app is older than the offline copy', async () => { + vi.mocked(isNativeIos).mockReturnValue(true); + vi.mocked(keepOfflineShellFresh).mockRejectedValue( + Object.assign(new Error('not implemented'), { code: 'UNIMPLEMENTED' }) + ); + const report = vi.fn(); + vi.stubGlobal('reportError', report); + startNativeApp(); + vi.advanceTimersByTime(15_000); + await vi.waitFor(() => expect(keepOfflineShellFresh).toHaveBeenCalled()); + // Let the rejection reach its handler. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(report).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); }); diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index 88ee8ff9b..ba57310d6 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -6,6 +6,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'; import { keepOfflineShellFresh } from './offlineShell'; import { isNativeApp, isNativeIos } from './platform'; import { reportNativeFailure } from './reportFailure'; +import { isUnimplemented } from './trekShell'; /** Long enough that the warm-up never competes with the first screen's requests. */ const OFFLINE_SHELL_DELAY_MS = 15_000; @@ -35,8 +36,11 @@ export function startNativeApp(): void { void Keyboard.setAccessoryBarVisible({ isVisible: false }); window.setTimeout(() => { if (isEffectivelyOffline()) return; - // Not refreshed this time is no harm: the next launch tries again. - keepOfflineShellFresh().catch(reportNativeFailure); + // Not refreshed this time is no harm: the next launch tries again. An app + // older than the offline copy has no such method, which is no failure. + keepOfflineShellFresh().catch((err: unknown) => { + if (!isUnimplemented(err)) reportNativeFailure(err); + }); }, OFFLINE_SHELL_DELAY_MS); } } diff --git a/client/src/native/serverAddress.test.ts b/client/src/native/serverAddress.test.ts index 6ed2692fb..a66508093 100644 --- a/client/src/native/serverAddress.test.ts +++ b/client/src/native/serverAddress.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { candidateUrls, probeServer, type ProbeResponse } from './serverAddress'; +import { candidateUrls, probeDemo, probeServer, type ProbeResponse } from './serverAddress'; const features = ( url: string, @@ -25,6 +25,18 @@ describe('candidateUrls', () => { }); describe('probeServer', () => { + it('accepts a server at the minimum native API version or above', async () => { + const data = { bookingImport: false, nativeApp: true, nativeApi: 1 }; + const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data)); + expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' }); + }); + + it('calls a server below the minimum native API version too old', async () => { + const data = { bookingImport: false, nativeApp: true, nativeApi: 0 }; + const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data)); + expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: false, reason: 'tooOld' }); + }); + it('accepts a server that reports nativeApp and follows its redirect', async () => { const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features')); expect(await probeServer('http://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' }); @@ -83,3 +95,11 @@ describe('probeServer', () => { expect(get).not.toHaveBeenCalled(); }); }); + +describe('probeDemo', () => { + it('probes the public demo server the address screen offers', async () => { + const get = vi.fn().mockResolvedValue(features('https://demo.liketrek.com/api/health/features')); + expect(await probeDemo(get)).toEqual({ ok: true, url: 'https://demo.liketrek.com' }); + expect(get).toHaveBeenCalledWith('https://demo.liketrek.com/api/health/features'); + }); +}); diff --git a/client/src/native/serverAddress.ts b/client/src/native/serverAddress.ts index d7e493e49..d2a51305f 100644 --- a/client/src/native/serverAddress.ts +++ b/client/src/native/serverAddress.ts @@ -1,3 +1,5 @@ +import { DEMO_SERVER_URL } from './links'; + /** * Turns what someone typed into the app's address field into a TREK server it * can load, or a reason it cannot. @@ -16,6 +18,13 @@ export type ProbeGet = (url: string) => Promise; const FEATURES_PATH = '/api/health/features'; +/** + * The oldest server API this app can work with (`nativeApi` in the features + * response). A server from before the field existed answers without it and is + * accepted while the minimum is 1. + */ +const MIN_NATIVE_API = 1; + function normalize(raw: string): string | null { let parsed: URL; try { @@ -47,6 +56,8 @@ function isRecord(value: unknown): value is Record { function judge(response: ProbeResponse): ProbeResult { if (response.status !== 200 || !isRecord(response.data)) return { ok: false, reason: 'notTrek' }; if (response.data.nativeApp === true) { + const api = response.data.nativeApi; + if (typeof api === 'number' && api < MIN_NATIVE_API) return { ok: false, reason: 'tooOld' }; // Follow a redirect (http to https, a canonical host) so the app loads the // address the server actually answers on. const base = response.url.endsWith(FEATURES_PATH) ? response.url.slice(0, -FEATURES_PATH.length) : null; @@ -74,3 +85,8 @@ export async function probeServer(input: string, get: ProbeGet): Promise { + return probeServer(DEMO_SERVER_URL, get); +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 596f991e4..548f12183 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -34,3 +34,12 @@ export interface TrekShellPlugin { export const TrekShell = registerPlugin('TrekShell'); export const APP_URL_SCHEME = 'com.liketrek.trek'; + +/** + * True for the rejection Capacitor gives when the installed app has no such + * native method: the UI comes from the server, so a newer server can call into + * an app binary that predates the method. + */ +export function isUnimplemented(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'UNIMPLEMENTED'; +} diff --git a/client/src/utils/donations.test.ts b/client/src/utils/donations.test.ts new file mode 100644 index 000000000..bf7f25823 --- /dev/null +++ b/client/src/utils/donations.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isNativeApp } from '../native/platform'; +import { donationsShown, isDonationLink, noticeAsksForDonation, withoutDonationsInApp } from './donations'; + +vi.mock('../native/platform', () => ({ isNativeApp: vi.fn() })); + +const links = [{ href: 'https://ko-fi.com/mauriceboe' }, { href: 'https://discord.gg/x' }]; + +beforeEach(() => { + vi.mocked(isNativeApp).mockReturnValue(false); +}); + +describe('isDonationLink', () => { + it('knows the donation pages, their subdomains too', () => { + expect(isDonationLink('https://ko-fi.com/mauriceboe')).toBe(true); + expect(isDonationLink('https://www.BuyMeACoffee.com/mauriceboe')).toBe(true); + }); + + it('is false for anything else, a missing href or one that is no URL', () => { + expect(isDonationLink('https://github.com/liketrek/TREK')).toBe(false); + expect(isDonationLink('https://notko-fi.com/x')).toBe(false); + expect(isDonationLink(undefined)).toBe(false); + expect(isDonationLink('not a url')).toBe(false); + }); +}); + +describe('in a browser', () => { + it('keeps every link and every notice', () => { + expect(donationsShown()).toBe(true); + expect(withoutDonationsInApp(links)).toEqual(links); + }); +}); + +describe('in the native app', () => { + it('drops the donation links', () => { + vi.mocked(isNativeApp).mockReturnValue(true); + expect(donationsShown()).toBe(false); + expect(withoutDonationsInApp(links)).toEqual([{ href: 'https://discord.gg/x' }]); + }); +}); + +describe('noticeAsksForDonation', () => { + it('looks at both buttons', () => { + expect(noticeAsksForDonation({ cta: { href: 'https://ko-fi.com/x' } })).toBe(true); + expect(noticeAsksForDonation({ secondaryCta: { href: 'https://buymeacoffee.com/x' } })).toBe(true); + expect(noticeAsksForDonation({ cta: { href: '/settings' }, secondaryCta: null })).toBe(false); + expect(noticeAsksForDonation({})).toBe(false); + }); +}); diff --git a/client/src/utils/donations.ts b/client/src/utils/donations.ts new file mode 100644 index 000000000..547dff238 --- /dev/null +++ b/client/src/utils/donations.ts @@ -0,0 +1,38 @@ +import { isNativeApp } from '../native/platform'; + +/** Where the project takes donations. */ +const DONATION_HOSTS = ['ko-fi.com', 'buymeacoffee.com']; + +/** Whether `href` points at one of the project's donation pages. */ +export function isDonationLink(href: string | undefined): boolean { + if (!href) return false; + let host: string; + try { + host = new URL(href).hostname.toLowerCase(); + } catch { + return false; + } + return DONATION_HOSTS.some((d) => host === d || host.endsWith(`.${d}`)); +} + +/** + * Whether the donation links may show. Not inside the iOS and Android apps: + * the store rules on payments (Apple 3.1.1, Google Play's Payments policy) + * allow no link to an outside payment page there. A browser keeps them. + */ +export function donationsShown(): boolean { + return !isNativeApp(); +} + +/** `links` without the donation pages inside the native apps, unchanged anywhere else. */ +export function withoutDonationsInApp(links: T[]): T[] { + return donationsShown() ? links : links.filter((link) => !isDonationLink(link.href)); +} + +/** Whether a system notice asks for a donation through one of its buttons. */ +export function noticeAsksForDonation(notice: { + cta?: { href?: string } | null; + secondaryCta?: { href?: string } | null; +}): boolean { + return isDonationLink(notice.cta?.href) || isDonationLink(notice.secondaryCta?.href); +} diff --git a/client/tests/unit/native/donations.native.test.tsx b/client/tests/unit/native/donations.native.test.tsx new file mode 100644 index 000000000..d816950e9 --- /dev/null +++ b/client/tests/unit/native/donations.native.test.tsx @@ -0,0 +1,94 @@ +// FE-NATIVE-DONATIONS-001 to FE-NATIVE-DONATIONS-005 +// Inside the iOS and Android apps no screen links to a donation page: the store +// rules on payments (Apple 3.1.1, Google Play Payments) allow no outside payment +// link there. Every place that shows one is rendered here with the app mocked in. +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import GitHubPanel from '../../../src/components/Admin/GitHubPanel'; +import AboutTab from '../../../src/components/Settings/AboutTab'; +import { SystemNoticeHost } from '../../../src/components/SystemNotices/SystemNoticeHost'; +import MAdminGitHubPanel from '../../../src/mobile/screens/admin/MAdminGitHubPanel'; +import MSettingsAbout from '../../../src/mobile/screens/settings/MSettingsAbout'; +import { useSystemNoticeStore, type SystemNoticeDTO } from '../../../src/store/systemNoticeStore'; +import { server } from '../../helpers/msw/server'; +import { render, screen, waitFor } from '../../helpers/render'; +import { resetAllStores } from '../../helpers/store'; + +vi.mock('../../../src/native/platform', () => ({ + isNativeApp: () => true, + isNativeIos: () => false, + nativeServerHost: () => 'trek.example.com', +})); +vi.mock('../../../src/components/SystemNotices/SystemNoticeModal.js', () => ({ + ModalRenderer: ({ notices }: { notices: SystemNoticeDTO[] }) => ( +
{notices.map((n) => n.id).join(',')}
+ ), +})); +vi.mock('../../../src/components/SystemNotices/SystemNoticeBanner.js', () => ({ + BannerRenderer: ({ notices }: { notices: SystemNoticeDTO[] }) => ( +
{notices.map((n) => n.id).join(',')}
+ ), + ToastRenderer: () => null, +})); + +function donationLinks(): Element[] { + return Array.from(document.querySelectorAll('a[href]')).filter((a) => + /ko-fi\.com|buymeacoffee\.com/.test(a.getAttribute('href') ?? '') + ); +} + +beforeEach(() => { + resetAllStores(); + server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([]))); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('donation links inside the native app', () => { + it('FE-NATIVE-DONATIONS-001: the desktop About tab shows none, but keeps the community links', () => { + render(); + expect(donationLinks()).toEqual([]); + expect(screen.getByText('Discord')).toBeInTheDocument(); + }); + + it('FE-NATIVE-DONATIONS-002: the phone About section shows none', () => { + render(); + expect(donationLinks()).toEqual([]); + expect(screen.getByText('Wiki')).toBeInTheDocument(); + }); + + it('FE-NATIVE-DONATIONS-003: the desktop admin GitHub panel shows none', async () => { + render(); + await waitFor(() => expect(screen.getByText('Discord')).toBeInTheDocument()); + expect(donationLinks()).toEqual([]); + }); + + it('FE-NATIVE-DONATIONS-004: the phone admin GitHub panel shows none', async () => { + render(); + await waitFor(() => expect(screen.getByText('Discord')).toBeInTheDocument()); + expect(donationLinks()).toEqual([]); + }); + + it('FE-NATIVE-DONATIONS-005: a system notice asking for a donation stays hidden, any other one shows', () => { + const base = { display: 'banner', severity: 'info', titleKey: 'Title', bodyKey: 'Body', dismissible: true }; + useSystemNoticeStore.setState({ + loaded: true, + fetching: false, + notices: [ + { + ...base, + id: 'support', + cta: { kind: 'link', labelKey: 'Coffee', href: 'https://buymeacoffee.com/mauriceboe' }, + secondaryCta: { kind: 'link', labelKey: 'Ko-fi', href: 'https://ko-fi.com/mauriceboe' }, + }, + { ...base, id: 'maintenance' }, + ] as unknown as SystemNoticeDTO[], + }); + render(); + expect(screen.getByTestId('banners')).toHaveTextContent('maintenance'); + expect(screen.getByTestId('banners')).not.toHaveTextContent('support'); + }); +}); diff --git a/server/src/nest/health/features.controller.ts b/server/src/nest/health/features.controller.ts index e2ff73249..93dfb022e 100644 --- a/server/src/nest/health/features.controller.ts +++ b/server/src/nest/health/features.controller.ts @@ -66,6 +66,10 @@ export class FeaturesController { // one serves a UI that knows neither the sign-in hand-off nor the way // back to the server picker, and the user would be stuck in it. nativeApp: true, + // The version of what the apps rely on from the server (the hand-off + // routes, the shell bridge in the served UI). Raised only for a change an + // older app cannot follow; the app refuses a server below its minimum. + nativeApi: 1, }; } } diff --git a/server/tests/unit/nest/features.controller.test.ts b/server/tests/unit/nest/features.controller.test.ts index 97db7500b..26038ea8f 100644 --- a/server/tests/unit/nest/features.controller.test.ts +++ b/server/tests/unit/nest/features.controller.test.ts @@ -37,6 +37,7 @@ describe('FeaturesController (GET /api/health/features)', () => { aiParsing: true, aiAssistant: true, nativeApp: true, + nativeApi: 1, }); }); @@ -47,6 +48,7 @@ describe('FeaturesController (GET /api/health/features)', () => { aiParsing: false, aiAssistant: false, nativeApp: true, + nativeApi: 1, }); }); @@ -56,12 +58,14 @@ describe('FeaturesController (GET /api/health/features)', () => { aiParsing: false, aiAssistant: false, nativeApp: true, + nativeApi: 1, }); expect(await make(false, true).controller.features()).toEqual({ bookingImport: false, aiParsing: true, aiAssistant: true, nativeApp: true, + nativeApi: 1, }); }); @@ -83,8 +87,10 @@ describe('FeaturesController (GET /api/health/features)', () => { } }); - it('FEAT-014: always tells the native apps this server speaks their sign-in hand-off', async () => { - expect((await make(false, false).controller.features()).nativeApp).toBe(true); + it('FEAT-014: tells the native apps it speaks their sign-in hand-off, and at which version', async () => { + const features = await make(false, false).controller.features(); + expect(features.nativeApp).toBe(true); + expect(features.nativeApi).toBe(1); }); it('FEAT-005: bookingImport is read live, not cached at construction', async () => { diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index ea1dbb8df..9b7611189 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'اتصال', 'native.connect.checking': 'جارٍ التحقق…', + 'native.connect.tryDemo': 'لا يوجد خادم بعد؟ جرّب النسخة التجريبية', + 'native.connect.whatIsServer': 'ما هو خادم TREK؟', 'native.connect.errorInvalid': 'لا يبدو هذا عنوان ويب.', 'native.connect.errorUnreachable': 'لا يستجيب أي خادم على هذا العنوان. تحقق من العنوان ومن اتصالك.', 'native.connect.errorNotTrek': 'الخادم على هذا العنوان ليس TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'لقد سجّلت الدخول باسم {name}. افتح تطبيق TREK لإكمال تسجيل الدخول هناك.', 'native.handoff.open': 'فتح تطبيق TREK', 'native.handoff.switchAccount': 'استخدام حساب آخر', + 'native.privacy': 'سياسة الخصوصية', 'native.settings.server': 'الخادم', 'native.settings.serverHint': 'هذا التطبيق متصل بـ {server}.', }; diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index 0f006edde..032d218bd 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Qoşul', 'native.connect.checking': 'Yoxlanılır…', + 'native.connect.tryDemo': 'Hələ serveriniz yoxdur? Demonu sınayın', + 'native.connect.whatIsServer': 'TREK serveri nədir?', 'native.connect.errorInvalid': 'Bu, veb ünvana bənzəmir.', 'native.connect.errorUnreachable': 'Bu ünvanda heç bir server cavab vermədi. Ünvanı və bağlantınızı yoxlayın.', 'native.connect.errorNotTrek': 'Bu ünvandakı server TREK deyil.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Siz {name} kimi daxil olmusunuz. Girişi orada tamamlamaq üçün TREK tətbiqini açın.', 'native.handoff.open': 'TREK tətbiqini aç', 'native.handoff.switchAccount': 'Başqa hesabdan istifadə et', + 'native.privacy': 'Məxfilik siyasəti', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Bu tətbiq {server} serverinə qoşulub.', }; diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index db571e832..6d555e279 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Conectar', 'native.connect.checking': 'Verificando…', + 'native.connect.tryDemo': 'Ainda não tem servidor? Experimente a demonstração', + 'native.connect.whatIsServer': 'O que é um servidor TREK?', 'native.connect.errorInvalid': 'Isso não parece um endereço da web.', 'native.connect.errorUnreachable': 'Nenhum servidor respondeu neste endereço. Verifique o endereço e sua conexão.', 'native.connect.errorNotTrek': 'O servidor neste endereço não é o TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Você entrou como {name}. Abra o app TREK para concluir o login por lá.', 'native.handoff.open': 'Abrir o app TREK', 'native.handoff.switchAccount': 'Usar outra conta', + 'native.privacy': 'Política de privacidade', 'native.settings.server': 'Servidor', 'native.settings.serverHint': 'Este app está conectado a {server}.', }; diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index 311d27113..0a883ac80 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Connecta', 'native.connect.checking': 'S’està comprovant…', + 'native.connect.tryDemo': 'Encara no tens servidor? Prova la demostració', + 'native.connect.whatIsServer': 'Què és un servidor TREK?', 'native.connect.errorInvalid': 'Això no sembla una adreça web.', 'native.connect.errorUnreachable': 'Cap servidor respon en aquesta adreça. Comprova l’adreça i la connexió.', 'native.connect.errorNotTrek': 'El servidor d’aquesta adreça no és TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Has iniciat la sessió com a {name}. Obre l’app de TREK per acabar d’iniciar-hi la sessió.', 'native.handoff.open': 'Obre l’app de TREK', 'native.handoff.switchAccount': 'Fes servir un altre compte', + 'native.privacy': 'Política de privadesa', 'native.settings.server': 'Servidor', 'native.settings.serverHint': 'Aquesta app està connectada a {server}.', }; diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index fae2d3566..aaab4fda7 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Připojit', 'native.connect.checking': 'Ověřování…', + 'native.connect.tryDemo': 'Ještě nemáte server? Vyzkoušejte demo', + 'native.connect.whatIsServer': 'Co je server TREK?', 'native.connect.errorInvalid': 'To nevypadá jako webová adresa.', 'native.connect.errorUnreachable': 'Na této adrese neodpovídá žádný server. Zkontrolujte adresu a připojení.', 'native.connect.errorNotTrek': 'Server na této adrese není TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Jste přihlášeni jako {name}. Otevřete aplikaci TREK a dokončete přihlášení v ní.', 'native.handoff.open': 'Otevřít aplikaci TREK', 'native.handoff.switchAccount': 'Použít jiný účet', + 'native.privacy': 'Zásady ochrany osobních údajů', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Tato aplikace je připojena k {server}.', }; diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index f1b4500c0..8a7474f80 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Verbinden', 'native.connect.checking': 'Wird geprüft…', + 'native.connect.tryDemo': 'Noch keinen Server? Probier die Demo aus', + 'native.connect.whatIsServer': 'Was ist ein TREK-Server?', 'native.connect.errorInvalid': 'Das sieht nicht nach einer Webadresse aus.', 'native.connect.errorUnreachable': 'Unter dieser Adresse antwortet kein Server. Prüfe die Adresse und deine Verbindung.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Du bist als {name} angemeldet. Öffne die TREK-App, um die Anmeldung dort abzuschließen.', 'native.handoff.open': 'TREK-App öffnen', 'native.handoff.switchAccount': 'Anderes Konto verwenden', + 'native.privacy': 'Datenschutzerklärung', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Diese App ist mit {server} verbunden.', }; diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 3d7a4bae5..8561f2d61 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Connect', 'native.connect.checking': 'Checking…', + 'native.connect.tryDemo': 'No server yet? Try the demo', + 'native.connect.whatIsServer': 'What is a TREK server?', 'native.connect.errorInvalid': 'That does not look like a web address.', 'native.connect.errorUnreachable': 'No server answered at this address. Check the address and your connection.', 'native.connect.errorNotTrek': 'The server at this address is not TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'You are signed in as {name}. Open the TREK app to finish signing in there.', 'native.handoff.open': 'Open the TREK app', 'native.handoff.switchAccount': 'Use a different account', + 'native.privacy': 'Privacy policy', 'native.settings.server': 'Server', 'native.settings.serverHint': 'This app is connected to {server}.', }; diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index d6b17fcf9..25044b2ad 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Conectar', 'native.connect.checking': 'Comprobando…', + 'native.connect.tryDemo': '¿Aún no tienes servidor? Prueba la demo', + 'native.connect.whatIsServer': '¿Qué es un servidor TREK?', 'native.connect.errorInvalid': 'Eso no parece una dirección web.', 'native.connect.errorUnreachable': 'Ningún servidor responde en esta dirección. Revisa la dirección y tu conexión.', 'native.connect.errorNotTrek': 'El servidor de esta dirección no es TREK.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Has iniciado sesión como {name}. Abre la app de TREK para terminar de iniciar sesión allí.', 'native.handoff.open': 'Abrir la app de TREK', 'native.handoff.switchAccount': 'Usar otra cuenta', + 'native.privacy': 'Política de privacidad', 'native.settings.server': 'Servidor', 'native.settings.serverHint': 'Esta app está conectada a {server}.', }; diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index a68213fa9..2ee2fb754 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Ühenda', 'native.connect.checking': 'Kontrollin…', + 'native.connect.tryDemo': 'Serverit veel pole? Proovi demot', + 'native.connect.whatIsServer': 'Mis on TREKi server?', 'native.connect.errorInvalid': 'See ei näe välja nagu veebiaadress.', 'native.connect.errorUnreachable': 'Sellel aadressil ei vastanud ükski server. Kontrolli aadressi ja oma ühendust.', 'native.connect.errorNotTrek': 'Sellel aadressil olev server ei ole TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Oled sisse logitud kui {name}. Ava TREKi rakendus, et sisselogimine seal lõpetada.', 'native.handoff.open': 'Ava TREKi rakendus', 'native.handoff.switchAccount': 'Kasuta teist kontot', + 'native.privacy': 'Privaatsuspoliitika', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'See rakendus on ühendatud serveriga {server}.', }; diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index ba7345b6f..ea97a8421 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Se connecter', 'native.connect.checking': 'Vérification…', + 'native.connect.tryDemo': 'Pas encore de serveur ? Essayez la démo', + 'native.connect.whatIsServer': 'Qu’est-ce qu’un serveur TREK ?', 'native.connect.errorInvalid': 'Cela ne ressemble pas à une adresse web.', 'native.connect.errorUnreachable': 'Aucun serveur ne répond à cette adresse. Vérifiez l’adresse et votre connexion.', 'native.connect.errorNotTrek': 'Le serveur à cette adresse n’est pas TREK.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { 'Vous êtes connecté en tant que {name}. Ouvrez l’application TREK pour y terminer la connexion.', 'native.handoff.open': 'Ouvrir l’application TREK', 'native.handoff.switchAccount': 'Utiliser un autre compte', + 'native.privacy': 'Politique de confidentialité', 'native.settings.server': 'Serveur', 'native.settings.serverHint': 'Cette application est connectée à {server}.', }; diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index f1f760d3c..b9e9da2a7 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Σύνδεση', 'native.connect.checking': 'Έλεγχος…', + 'native.connect.tryDemo': 'Δεν έχετε ακόμη διακομιστή; Δοκιμάστε την επίδειξη', + 'native.connect.whatIsServer': 'Τι είναι ένας διακομιστής TREK;', 'native.connect.errorInvalid': 'Αυτό δεν μοιάζει με διεύθυνση ιστού.', 'native.connect.errorUnreachable': 'Κανένας διακομιστής δεν απαντά σε αυτή τη διεύθυνση. Ελέγξτε τη διεύθυνση και τη σύνδεσή σας.', @@ -21,6 +23,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Έχετε συνδεθεί ως {name}. Ανοίξτε την εφαρμογή TREK για να ολοκληρώσετε εκεί τη σύνδεση.', 'native.handoff.open': 'Άνοιγμα της εφαρμογής TREK', 'native.handoff.switchAccount': 'Χρήση άλλου λογαριασμού', + 'native.privacy': 'Πολιτική απορρήτου', 'native.settings.server': 'Διακομιστής', 'native.settings.serverHint': 'Αυτή η εφαρμογή είναι συνδεδεμένη στο {server}.', }; diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 7a56126d3..a27472897 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Csatlakozás', 'native.connect.checking': 'Ellenőrzés…', + 'native.connect.tryDemo': 'Még nincs szervered? Próbáld ki a demót', + 'native.connect.whatIsServer': 'Mi az a TREK-szerver?', 'native.connect.errorInvalid': 'Ez nem úgy néz ki, mint egy webcím.', 'native.connect.errorUnreachable': 'Ezen a címen nem válaszol szerver. Ellenőrizd a címet és a kapcsolatot.', 'native.connect.errorNotTrek': 'Az ezen a címen futó szerver nem TREK.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { '{name} néven vagy bejelentkezve. Nyisd meg a TREK alkalmazást, hogy ott befejezd a bejelentkezést.', 'native.handoff.open': 'TREK alkalmazás megnyitása', 'native.handoff.switchAccount': 'Másik fiók használata', + 'native.privacy': 'Adatvédelmi irányelvek', 'native.settings.server': 'Szerver', 'native.settings.serverHint': 'Ez az alkalmazás ehhez csatlakozik: {server}.', }; diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 8cff16ebc..6b9b497c5 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Hubungkan', 'native.connect.checking': 'Memeriksa…', + 'native.connect.tryDemo': 'Belum punya server? Coba demonya', + 'native.connect.whatIsServer': 'Apa itu server TREK?', 'native.connect.errorInvalid': 'Itu tidak terlihat seperti alamat web.', 'native.connect.errorUnreachable': 'Tidak ada server yang merespons di alamat ini. Periksa alamat dan koneksimu.', 'native.connect.errorNotTrek': 'Server di alamat ini bukan TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Kamu masuk sebagai {name}. Buka aplikasi TREK untuk menyelesaikan proses masuk di sana.', 'native.handoff.open': 'Buka aplikasi TREK', 'native.handoff.switchAccount': 'Gunakan akun lain', + 'native.privacy': 'Kebijakan privasi', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Aplikasi ini terhubung ke {server}.', }; diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index 11c398fea..8e812103b 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Connetti', 'native.connect.checking': 'Verifica in corso…', + 'native.connect.tryDemo': 'Non hai ancora un server? Prova la demo', + 'native.connect.whatIsServer': 'Che cos’è un server TREK?', 'native.connect.errorInvalid': 'Non sembra un indirizzo web.', 'native.connect.errorUnreachable': 'Nessun server risponde a questo indirizzo. Controlla l’indirizzo e la connessione.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Hai effettuato l’accesso come {name}. Apri l’app TREK per completare lì l’accesso.', 'native.handoff.open': 'Apri l’app TREK', 'native.handoff.switchAccount': 'Usa un altro account', + 'native.privacy': 'Informativa sulla privacy', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Questa app è collegata a {server}.', }; diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index cb662f652..3cc41f5a6 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': '接続', 'native.connect.checking': '確認中…', + 'native.connect.tryDemo': 'まだサーバーがありませんか?デモをお試しください', + 'native.connect.whatIsServer': 'TREK サーバーとは?', 'native.connect.errorInvalid': 'Web アドレスではないようです。', 'native.connect.errorUnreachable': 'このアドレスで応答するサーバーがありません。アドレスと接続を確認してください。', 'native.connect.errorNotTrek': 'このアドレスのサーバーは TREK ではありません。', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': '{name} としてログインしています。TREK アプリを開いて、そちらでログインを完了してください。', 'native.handoff.open': 'TREK アプリを開く', 'native.handoff.switchAccount': '別のアカウントを使う', + 'native.privacy': 'プライバシーポリシー', 'native.settings.server': 'サーバー', 'native.settings.serverHint': 'このアプリは {server} に接続されています。', }; diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index 1f05fb7b0..50667dffd 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': '연결', 'native.connect.checking': '확인 중…', + 'native.connect.tryDemo': '아직 서버가 없나요? 데모를 사용해 보세요', + 'native.connect.whatIsServer': 'TREK 서버란?', 'native.connect.errorInvalid': '웹 주소가 아닌 것 같습니다.', 'native.connect.errorUnreachable': '이 주소에서 응답하는 서버가 없습니다. 주소와 연결 상태를 확인하세요.', 'native.connect.errorNotTrek': '이 주소의 서버는 TREK이 아닙니다.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': '{name}(으)로 로그인되어 있습니다. TREK 앱을 열어 로그인을 완료하세요.', 'native.handoff.open': 'TREK 앱 열기', 'native.handoff.switchAccount': '다른 계정 사용', + 'native.privacy': '개인정보 처리방침', 'native.settings.server': '서버', 'native.settings.serverHint': '이 앱은 {server}에 연결되어 있습니다.', }; diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 617505830..477e15fb7 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Verbinden', 'native.connect.checking': 'Controleren…', + 'native.connect.tryDemo': 'Nog geen server? Probeer de demo', + 'native.connect.whatIsServer': 'Wat is een TREK-server?', 'native.connect.errorInvalid': 'Dat lijkt geen webadres.', 'native.connect.errorUnreachable': 'Er reageert geen server op dit adres. Controleer het adres en je verbinding.', 'native.connect.errorNotTrek': 'De server op dit adres is geen TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Je bent ingelogd als {name}. Open de TREK-app om daar het inloggen af te ronden.', 'native.handoff.open': 'TREK-app openen', 'native.handoff.switchAccount': 'Ander account gebruiken', + 'native.privacy': 'Privacybeleid', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Deze app is verbonden met {server}.', }; diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 744df5182..da15ab95d 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Połącz', 'native.connect.checking': 'Sprawdzanie…', + 'native.connect.tryDemo': 'Nie masz jeszcze serwera? Wypróbuj demo', + 'native.connect.whatIsServer': 'Czym jest serwer TREK?', 'native.connect.errorInvalid': 'To nie wygląda na adres internetowy.', 'native.connect.errorUnreachable': 'Pod tym adresem nie odpowiada żaden serwer. Sprawdź adres i połączenie.', 'native.connect.errorNotTrek': 'Serwer pod tym adresem to nie TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Zalogowano jako {name}. Otwórz aplikację TREK, aby tam dokończyć logowanie.', 'native.handoff.open': 'Otwórz aplikację TREK', 'native.handoff.switchAccount': 'Użyj innego konta', + 'native.privacy': 'Polityka prywatności', 'native.settings.server': 'Serwer', 'native.settings.serverHint': 'Ta aplikacja jest połączona z {server}.', }; diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 9c4b429fa..cc5a38724 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Подключиться', 'native.connect.checking': 'Проверка…', + 'native.connect.tryDemo': 'Ещё нет сервера? Попробуйте демо', + 'native.connect.whatIsServer': 'Что такое сервер TREK?', 'native.connect.errorInvalid': 'Это не похоже на веб-адрес.', 'native.connect.errorUnreachable': 'По этому адресу не отвечает ни один сервер. Проверьте адрес и подключение.', 'native.connect.errorNotTrek': 'Сервер по этому адресу не является TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Вы вошли как {name}. Откройте приложение TREK, чтобы завершить вход в нём.', 'native.handoff.open': 'Открыть приложение TREK', 'native.handoff.switchAccount': 'Использовать другой аккаунт', + 'native.privacy': 'Политика конфиденциальности', 'native.settings.server': 'Сервер', 'native.settings.serverHint': 'Приложение подключено к {server}.', }; diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index 898c47736..2faba5d44 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Pripojiť', 'native.connect.checking': 'Overuje sa…', + 'native.connect.tryDemo': 'Ešte nemáte server? Vyskúšajte demo', + 'native.connect.whatIsServer': 'Čo je server TREK?', 'native.connect.errorInvalid': 'Toto nevyzerá ako webová adresa.', 'native.connect.errorUnreachable': 'Na tejto adrese neodpovedal žiadny server. Skontrolujte adresu a pripojenie.', 'native.connect.errorNotTrek': 'Server na tejto adrese nie je TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Ste prihlásení ako {name}. Otvorte aplikáciu TREK a dokončite prihlásenie v nej.', 'native.handoff.open': 'Otvoriť aplikáciu TREK', 'native.handoff.switchAccount': 'Použiť iný účet', + 'native.privacy': 'Zásady ochrany osobných údajov', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Táto aplikácia je pripojená k serveru {server}.', }; diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index 79a5fca03..48fdac8c4 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Anslut', 'native.connect.checking': 'Kontrollerar…', + 'native.connect.tryDemo': 'Ingen server än? Testa demon', + 'native.connect.whatIsServer': 'Vad är en TREK-server?', 'native.connect.errorInvalid': 'Det ser inte ut som en webbadress.', 'native.connect.errorUnreachable': 'Ingen server svarar på den här adressen. Kontrollera adressen och din anslutning.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Du är inloggad som {name}. Öppna TREK-appen för att slutföra inloggningen där.', 'native.handoff.open': 'Öppna TREK-appen', 'native.handoff.switchAccount': 'Använd ett annat konto', + 'native.privacy': 'Integritetspolicy', 'native.settings.server': 'Server', // same-as-en 'native.settings.serverHint': 'Den här appen är ansluten till {server}.', }; diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index edd5f1f42..9f7a3c1ff 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'เชื่อมต่อ', 'native.connect.checking': 'กำลังตรวจสอบ…', + 'native.connect.tryDemo': 'ยังไม่มีเซิร์ฟเวอร์ใช่ไหม ลองใช้เดโม', + 'native.connect.whatIsServer': 'เซิร์ฟเวอร์ TREK คืออะไร', 'native.connect.errorInvalid': 'ดูเหมือนว่านี่ไม่ใช่ที่อยู่เว็บ', 'native.connect.errorUnreachable': 'ไม่มีเซิร์ฟเวอร์ตอบกลับที่ที่อยู่นี้ โปรดตรวจสอบที่อยู่และการเชื่อมต่อของคุณ', 'native.connect.errorNotTrek': 'เซิร์ฟเวอร์ที่ที่อยู่นี้ไม่ใช่ TREK', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'คุณเข้าสู่ระบบในชื่อ {name} เปิดแอป TREK เพื่อเข้าสู่ระบบให้เสร็จสิ้นที่นั่น', 'native.handoff.open': 'เปิดแอป TREK', 'native.handoff.switchAccount': 'ใช้บัญชีอื่น', + 'native.privacy': 'นโยบายความเป็นส่วนตัว', 'native.settings.server': 'เซิร์ฟเวอร์', 'native.settings.serverHint': 'แอปนี้เชื่อมต่อกับ {server} อยู่', }; diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index 9bdcc998d..8998140ef 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Bağlan', 'native.connect.checking': 'Kontrol ediliyor…', + 'native.connect.tryDemo': 'Henüz sunucun yok mu? Demoyu dene', + 'native.connect.whatIsServer': 'TREK sunucusu nedir?', 'native.connect.errorInvalid': 'Bu bir web adresine benzemiyor.', 'native.connect.errorUnreachable': 'Bu adreste yanıt veren bir sunucu yok. Adresi ve bağlantını kontrol et.', 'native.connect.errorNotTrek': 'Bu adresteki sunucu TREK değil.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': '{name} olarak giriş yaptın. Girişi tamamlamak için TREK uygulamasını aç.', 'native.handoff.open': 'TREK uygulamasını aç', 'native.handoff.switchAccount': 'Başka bir hesap kullan', + 'native.privacy': 'Gizlilik politikası', 'native.settings.server': 'Sunucu', 'native.settings.serverHint': 'Bu uygulama {server} adresine bağlı.', }; diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index de94bb0ae..2da8ec2c0 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Підключитися', 'native.connect.checking': 'Перевірка…', + 'native.connect.tryDemo': 'Ще немає сервера? Спробуйте демо', + 'native.connect.whatIsServer': 'Що таке сервер TREK?', 'native.connect.errorInvalid': 'Це не схоже на вебадресу.', 'native.connect.errorUnreachable': 'За цією адресою не відповідає жоден сервер. Перевірте адресу та з’єднання.', 'native.connect.errorNotTrek': 'Сервер за цією адресою не є TREK.', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Ви увійшли як {name}. Відкрийте застосунок TREK, щоб завершити вхід у ньому.', 'native.handoff.open': 'Відкрити застосунок TREK', 'native.handoff.switchAccount': 'Використати інший обліковий запис', + 'native.privacy': 'Політика конфіденційності', 'native.settings.server': 'Сервер', 'native.settings.serverHint': 'Застосунок підключено до {server}.', }; diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index f7dc8bde7..3402c7a56 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': 'Kết nối', 'native.connect.checking': 'Đang kiểm tra…', + 'native.connect.tryDemo': 'Chưa có máy chủ? Dùng thử bản demo', + 'native.connect.whatIsServer': 'Máy chủ TREK là gì?', 'native.connect.errorInvalid': 'Đây có vẻ không phải là địa chỉ web.', 'native.connect.errorUnreachable': 'Không có máy chủ nào phản hồi tại địa chỉ này. Hãy kiểm tra địa chỉ và kết nối của bạn.', @@ -20,6 +22,7 @@ const native: TranslationStrings = { 'native.handoff.hint': 'Bạn đã đăng nhập với tên {name}. Mở ứng dụng TREK để hoàn tất đăng nhập ở đó.', 'native.handoff.open': 'Mở ứng dụng TREK', 'native.handoff.switchAccount': 'Dùng tài khoản khác', + 'native.privacy': 'Chính sách quyền riêng tư', 'native.settings.server': 'Máy chủ', 'native.settings.serverHint': 'Ứng dụng này đang kết nối với {server}.', }; diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 9486311d5..1dd7607bb 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': '連線', 'native.connect.checking': '正在檢查…', + 'native.connect.tryDemo': '還沒有伺服器?試試示範版', + 'native.connect.whatIsServer': '什麼是 TREK 伺服器?', 'native.connect.errorInvalid': '這看起來不像網址。', 'native.connect.errorUnreachable': '此網址沒有伺服器回應。請檢查網址與網路連線。', 'native.connect.errorNotTrek': '此網址上的伺服器不是 TREK。', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': '你已以 {name} 身分登入。請開啟 TREK App,在那裡完成登入。', 'native.handoff.open': '開啟 TREK App', 'native.handoff.switchAccount': '使用其他帳號', + 'native.privacy': '隱私權政策', 'native.settings.server': '伺服器', 'native.settings.serverHint': '此 App 已連線到 {server}。', }; diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index 701b06acc..68c6367d3 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -6,6 +6,8 @@ const native: TranslationStrings = { 'native.connect.placeholder': 'https://trek.example.com', 'native.connect.submit': '连接', 'native.connect.checking': '正在检查…', + 'native.connect.tryDemo': '还没有服务器?试试演示版', + 'native.connect.whatIsServer': '什么是 TREK 服务器?', 'native.connect.errorInvalid': '这看起来不像网址。', 'native.connect.errorUnreachable': '此地址没有服务器响应。请检查地址和网络连接。', 'native.connect.errorNotTrek': '此地址上的服务器不是 TREK。', @@ -19,6 +21,7 @@ const native: TranslationStrings = { 'native.handoff.hint': '你已以 {name} 身份登录。请打开 TREK 应用,在那里完成登录。', 'native.handoff.open': '打开 TREK 应用', 'native.handoff.switchAccount': '使用其他账号', + 'native.privacy': '隐私政策', 'native.settings.server': '服务器', 'native.settings.serverHint': '此应用已连接到 {server}。', }; diff --git a/wiki/Native-Apps.md b/wiki/Native-Apps.md new file mode 100644 index 000000000..59ca199f3 --- /dev/null +++ b/wiki/Native-Apps.md @@ -0,0 +1,76 @@ +# Native Apps + +**TREK Travel Planner** is the TREK app for iPhone, iPad and Android, from the App Store and Google Play. It is a client for your TREK server: it asks for the server's address once and then shows the TREK interface that server delivers, in the version the server runs, with the same data, the same accounts and the same settings as in the browser. On the home screen it is called **TREK**. + +The browser keeps working as before, and installing TREK from the browser as a web app is still possible; see [Offline-Mode-and-PWA](Offline-Mode-and-PWA). The app adds what a browser cannot do well on a phone: signing in with single sign-on and passkeys, the system share sheet for exports, haptics, and on iOS a start without network. + +## Connecting to a server + +On first start the app shows its address screen. Enter the address you open TREK at in the browser, for example `trek.example.com` or `192.168.1.10:3001`. Without a scheme the app tries https first and then plain http. + +The app checks that the address answers as a TREK server that knows the apps, and then loads it. If it does not, it says why: + +- **No server answered**: the address is wrong, the server is down, or the phone cannot reach it (see the network section below). +- **Not TREK**: something answered, but not a TREK server. +- **Too old**: a TREK server from before the apps. Update it to a current release. + +No server yet? **Try the demo** connects to the public demo at demo.liketrek.com, which everybody shares and which is reset every hour. **What is a TREK server?** leads to the project page, which explains how to run your own. + +To switch servers later, open **Settings → Account** in the app: the **Server** card names the server and has **Change server**, which returns to the address screen. The sign-in screen has the same link at the bottom. + +## Signing in + +A password works inside the app as in the browser, and **Remember me** is on by default, so the app does not ask again after the phone closed it. + +Single sign-on and passkeys cannot run inside the app's web view: identity providers refuse embedded browsers, and a passkey belongs to your server's domain, which the app cannot be tied to for every server. So the app opens your server's sign-in in the system browser instead. Sign in there as usual, tap **Open the TREK app**, and the app continues signed in. The browser hands over a one-time code that only the app that asked for it can redeem, valid for a minute. The server records the sign-in as a login from the native app. + +Passkeys are managed in the browser for the same reason: the **Passkeys** card is hidden in the app. + +## Offline + +The app keeps the trips you choose offline the same way the browser does, including their maps; see [Offline-Mode-and-PWA](Offline-Mode-and-PWA). On iOS, where the web view runs no service worker, the app stores a copy of the server's start page and of every script of its current version, so it also starts with no network. When the server cannot be reached on start, the app opens that copy, or offers **Try again** and **Change server**. + +## Plain http, the home network and VPNs + +The app accepts plain http on purpose, because a server at home is often reached as `http://192.168.x.x:3001` or over a VPN such as Tailscale. Use https for anything reachable from the internet. iOS asks once for access to the local network; allow it, or the app cannot reach a server on your network. + +## Files and downloads + +Exports, attachments and backup codes open in the system share sheet, from where you save them to Files, open them in another app or send them on. + +## Privacy + +The app sends what you enter to your TREK server and nowhere else; the makers of TREK receive nothing. Map tiles come from the map provider your server is set up with. The privacy policy is at https://liketrek.com/privacy and is linked from the address screen and from **Settings → Account**. How to delete an account: https://liketrek.com/delete-account. + +The app hides the donation links (Ko-fi, Buy Me a Coffee) that the browser shows, because the store rules allow no outside payment links in an app. + +## For maintainers: releasing the apps + +The apps are built from `client/`: Capacitor wraps the address screen in `client/native-shell/`, and the iOS and Android projects are `client/ios/` and `client/android/`. + +- **Every pull request** that touches the native code runs `.github/workflows/native.yml`: an unsigned Android debug and release build (the release build runs R8 with `client/android/app/proguard-rules.pro`), an iOS simulator build with Xcode 26, and a check of the store texts and images. +- **A release** runs `.github/workflows/native-release.yml`, by hand (choose the Play track) or on a `v*` tag. A tag pushed by another workflow with the default token does not start it, so after a release run it by hand on the tag. It builds the signed Android App Bundle and sends it to the chosen Google Play testing track as a draft, and archives the iOS app and uploads it to App Store Connect, where it appears in TestFlight. Promoting a build to production happens in the two consoles. +- **Version numbers**: both stores get the version from `client/package.json` without a prerelease suffix, and the workflow run number plus 1000 as the build number. A prerelease reuses its version with a higher build number and stays on the internal track and in TestFlight. +- **The `store` environment** holds every secret and needs the approval of a required reviewer before a job starts. It only runs in liketrek/TREK, never for a pull request. + +### Secrets in the `store` environment + +| Secret | What | +| --- | --- | +| ANDROID_UPLOAD_KEYSTORE_B64 | The upload keystore, base64 encoded | +| ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD | Its password, key alias and key password | +| PLAY_SERVICE_ACCOUNT_JSON | The Google Cloud service account with "Release to testing tracks" in Play Console | +| APPLE_TEAM_ID | The Apple Developer team of [APPLE ORGANISATION ACCOUNT] | +| ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_B64 | The App Store Connect team API key (App Manager role), the .p8 file base64 encoded | + +The Play account is [GOOGLE PLAY ORGANISATION ACCOUNT], the Apple account [APPLE ORGANISATION ACCOUNT]. Google Play App Signing holds the app signing key; the upload key above only proves a build comes from us and can be reset through Play support if lost. Its offline backup lives at [KEYSTORE BACKUP LOCATION]. Never commit a keystore, a .p8 key or a service account file: `client/android/.gitignore` ignores them. + +The first Android bundle has to be uploaded by hand once (Play only accepts API uploads after that): run the release workflow, download the bundle from the run's artifacts and upload it to Internal testing in Play Console. + +### Store listings + +The listing texts, the review notes, the policy page drafts and the screenshots live in `client/fastlane/`; its `client/fastlane/README.md` explains how to make the screenshots and upload the listings. App Review gets a separate review server, [REVIEW SERVER URL], with its own accounts, because the public demo blocks account deletion and uploads; the review notes in App Store Connect name it. + +### The server side + +The app relies on two things of the server: `/api/health/features` answers `nativeApp: true` and `nativeApi` (the version of what the apps use), and `/api/auth/native` carries the sign-in hand-off. `nativeApi` is raised only for a change an older app cannot follow; the app refuses a server below the minimum it knows. Keep demo.liketrek.com on a current release for as long as the apps are in the stores, since **Try the demo** connects to it. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index f6cd68af2..3707c489f 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -23,6 +23,7 @@ - [[Dashboard Widgets|Dashboard-Widgets]] - [[Notifications Inbox|Notifications-Inbox]] - [[TREK on a Phone|Mobile-App]] +- [[Native Apps|Native-Apps]] - [[Offline Mode and PWA|Offline-Mode-and-PWA]] - [[Languages]]