Compare commits

..

37 Commits

Author SHA1 Message Date
Maurice c42bbc4239 chore(native): re-trigger CI on the formatted tree 2026-11-05 17:22:51 +01:00
Maurice a9ffcbc134 style: prettier-format the native and i18n changes
The feature-branch CI does not run the formatting check, so the conflict-resolved
files (AboutTab, useAccountSettings, useTripExport), the native shell and iap
module, and the i18n files touched by the translation scripts were never run
through Prettier. Format them all; no behaviour changes.
2026-11-05 17:11:47 +01:00
Maurice 91defd40fd fix(native): a flat redeem result, so the claim flow typechecks
The discriminated union did not narrow under the native-shell tsconfig, so the
failure code read as absent on the whole type. Use a flat result with optional
instanceUrl and code instead: both are always present on the type, no narrowing
needed, and the ok plus instanceUrl guard still routes success from refusal.
2026-11-05 16:53:04 +01:00
Maurice bde3bb4a57 fix(native): typecheck, lint and knip on the native code; wire he's native bundle
The full suite, which the feature-branch CI does not run, catches four things the
native work still had: a non-narrowed union in the claim flow (tsc), a helper
named useFake that the react-hooks rule read as a hook (eslint), two iap types
exported but used only inside the file (knip), and the Hebrew native strings
present as a file but never spread into he's merged bundle (the aggregated key
parity test). All four fixed.
2026-11-05 16:44:31 +01:00
Maurice cc01f7df32 i18n(native): excuse the TREK Cloud brand, mark Email same-as-en, exempt the quota counts
The strict i18n checks, which the feature-branch CI does not run, catch three
things now that the native strings exist in every locale: TREK Cloud read as an
untranslated English phrase (it is a product name, like Vacay and Atlas, so add
it to the brand list); Email left identical in Italian, Indonesian and
Vietnamese, where it is the local word too (mark same-as-en) and in Greek (give
it a Greek term); and the GB and AI-document quota strings carrying {n} without
being plural groups (both are figures that never show a singular, like the other
unit-symbol counts in NOT_PLURAL).
2026-11-05 16:38:37 +01:00
Maurice 80de282e1a fix(i18n): add the Hebrew native strings, and correct the LoginPage size baseline
The he locale had every domain but native.ts, which broke i18n parity, the shared
coverage specs and the client parity test once the native strings existed. Add
he/native.ts with all native keys translated. Also correct the LoginPage size
baseline to its current line count.
2026-11-05 16:22:08 +01:00
Maurice 870b03c6c8 i18n(native): translate the app into the remaining 20 languages
The onboarding, plan picker, claim and provisioning strings were English
placeholders outside en, de and the five largest European languages. They are
now translated in full across the other 20 locales (ar, az, br, ca, cs, et, gr,
hu, id, ja, ko, ru, sk, sv, th, tr, uk, vi, zh, zh-TW), placeholders and brand
kept. The app now speaks every language TREK does.
2026-11-05 15:09:02 +01:00
Maurice 037e739807 feat(native): the in-app TREK Cloud subscription flow
After picking a plan, the app now carries the whole purchase itself when the build
can take one: choose your address (checked for free as you type, against the same
endpoint the website uses), your email, and subscribe. The receipt goes to
/api/iap/redeem, and a Setting up your TREK screen waits on the new address and
loads it the moment it answers.

The purchase sits behind an IapClient seam (src/native/iap.ts): a fake drives the
whole flow in development and preview, the native client reports itself
unavailable until the store plugin and products exist, and until then the plan
picker still finishes on the web, so nothing half-wired faces a customer. New
claim and provisioning strings across all locales (en, de and the five largest
European languages translated).
2026-11-05 14:36:21 +01:00
Maurice d02e58387d i18n(native): translate the onboarding into es, fr, it, nl and pl
The welcome, TREK Cloud and plan-picker strings were English placeholders in
every locale but en and de. The five largest remaining European languages are
now translated in full; the others still carry the English text and are a
follow-up for native speakers.
2026-11-05 14:15:23 +01:00
Maurice a6796765a2 feat(native): light status bar and a Go key on the address screen
The address screen sits on the near-black surface, so set the status bar to draw
its clock and icons in light while it is up; the server's own UI takes over the
style once it loads. The address field's on-screen return key now reads Go. Both
fail quietly where there is no native status bar (web, the simulator).
2026-11-05 14:00:36 +01:00
Maurice 5a62aa4541 feat(native): a plan picker for TREK Cloud, with live prices
The Get TREK Cloud path now leads to a plan picker that reads the live plans
from the site (/api/plans), so the prices and quotas always match the website.
Monthly and yearly in a segmented toggle, the year marked with its saving, each
plan a card with its price and what it includes. Subscribe is wired to finish on
the web order for now; it becomes an Apple/Google in-app purchase once the store
products exist. New strings across all locales (en and de translated).
2026-11-05 13:34:47 +01:00
Maurice 9790ad28e1 feat(native): a welcoming onboarding for the address screen
The app's own screen now opens on a welcome: the TREK mark, a line about what
TREK is, and two clear paths — connect the TREK you already open in the browser,
or get TREK Cloud. Both lead to polished screens: the address field with live
validation, a shake and haptic on a bad address and a success tick before the
server takes over; and a TREK Cloud intro with what it offers and a way in.
Screens slide and stagger in, buttons give a tap, and it all respects the safe
areas, light and dark, and reduced motion. Self-hosters are unaffected: cloud is
its own path, never in the way. New native strings are added across all locales
(English placeholders outside en/de, to translate).
2026-11-05 13:23:48 +01:00
Maurice 2ce44a12c5 Merge remote-tracking branch 'liketrek/dev' into resolve/native-apps
# Conflicts:
#	client/scripts/size-baseline.json
#	client/src/components/Planner/useTripExport.ts
#	client/src/components/Settings/AboutTab.tsx
#	client/src/components/Settings/useAccountSettings.ts
2026-11-05 13:07:08 +01:00
Maurice cb6149f8ba fix(native): read a notice button's href without a weak type
An action button carries no href, so a parameter typed as only an optional href refused the notice DTO; the button type now names its kind as well.
2026-10-25 18:58:27 +01:00
Maurice 8963180006 feat(native): everything the App Store and Google Play submissions need
- iOS: microphone usage text for "Take Video", no-export-compliance flag,
  ExportOptions.plist for App Store Connect uploads, the unused push
  forwarders gone; CI builds with Xcode 26 and lints the property lists.
- Android: version and signing from Gradle properties (unsigned without
  them), R8 with keep rules for TREK's and Capacitor's reflective classes,
  no Auto Backup of the session cookie, no external file provider path,
  template and Firebase leftovers removed, keystores and service accounts
  ignored, full-size adaptive icon layers with a monochrome layer; CI also
  builds the release APK.
- native-release.yml: signed bundle to a Play testing track and archive to
  TestFlight, by hand or on a v* tag, behind the store environment.
- Version handshake: the server reports nativeApi 1, the app refuses a
  server below its minimum and tolerates an app older than the offline copy.
- Address screen: privacy policy link, "Try the demo" and "What is a TREK
  server?"; the privacy link also in the app's server settings. Three new
  native strings in all 27 locales.
- Donation links and the notices asking for one are hidden in the app.
- Store listings for "TREK Travel Planner" in client/fastlane: texts, review
  notes with placeholders, privacy and account deletion page drafts, the
  feature graphic and icon, a length and image check in CI, and a Playwright
  store project with the scripts that turn its captures into screenshots.
- Docs: wiki/Native-Apps.md, README no longer says there is no store app.
2026-10-25 18:37:31 +01:00
Maurice 5c37dd43c5 fix(native): hide passkeys in the app through usePasskeys
The flag in useAccountSettings grew the identical destructuring in both account views past the duplication threshold; the shared passkeys hook already decides when its card is hidden, so the app check lives there now.
2026-10-25 18:02:45 +01:00
Maurice 6f3c051c7c fix(native): Prettier on the native client files, rate-limit e2e requests in sequence
Formats the files the client formatting gate listed, and NATIVE-E2E-005 sends its twenty exchange attempts one after another: all at once they reset the test server's connections.
2026-10-25 17:41:32 +01:00
Maurice 99c1a2d6c7 fix(native): strict types in the address screen, Prettier on the native-auth e2e suite, file-local exports
The address screen's element helper returns the plain element instead of Object.assign's intersection, which strict mode could not represent; the haptics test reads an optional argument as optional; three exports only their own file used are local now.
2026-10-25 17:26:03 +01:00
Maurice a3b04887a1 fix(native): new lint warnings and the MapLibre protocol type after the dev merge
Awaits that sat in loops in the native-auth tests run together, the hand-off hook marks its navigations as ignored, the native tests drop their non-null assertions, and the offline protocol answers with the data type MapLibre 6.15 expects.
2026-10-25 17:16:02 +01:00
Maurice 8b6a65023f Merge dev into feat/native-apps
Brings the native apps branch up to dev (NestJS-only server, MikroORM
repositories, tracked sessions, per-domain locales, the lint ratchets) and
ports its new code onto that architecture:

- server: /api/auth/native moves onto injected services. The session is
  minted by the auth domain's new LoginSessionService (login bookkeeping and
  SessionsService.issue in one transaction), the one-time codes live behind a
  NativeHandoffStore port with a capped in-memory implementation that drops
  expired codes on write instead of running a timer, both routes declare
  response contracts, and the exchange answers { success: true }. Unit and
  e2e suites run on createTestModule and the ORM factories.
- shared: native strings for az, et, sk and th, native wired into every
  locale, the address placeholder as a full URL.
- client: the account and export changes go into dev's shared hooks
  (useAccountSettings, useTripExport), the native server card is a
  desktop/phone pair on useNativeServer, the hand-off call moves out of the
  page hook into native/, best-effort native failures go through
  reportError instead of the console, and the address screen is known to
  knip and lint:i18n-keys.
- package-lock.json regenerated on Linux.
2026-10-25 17:04:15 +01:00
Maurice 7572c610a6 feat(native): TREK's own app icon and launch screen, named just TREK
The shells still carried Capacitor's placeholder icon and splash, the blue
X on a grid. They now show the white TREK mark on a dark, slightly glassy
tile: a soft light from the top left and a sheen over the upper half, in
the app's launch colour #09090b. Android 12+ draws its launch screen on the
same colour instead of light grey.

scripts/generate-app-icons.mjs renders the sources into assets/ from
public/icons/icon-white.svg, and `npm run icons:app` turns them into every
size both platforms ask for through @capacitor/assets. The Android
foreground mark is sized for the adaptive icon's round mask.

The home screen label is TREK: the old "TREK - Plan your trips!" was cut
off under the icon, and the longer title belongs to the store listings.
2026-09-24 14:50:25 +02:00
Maurice f567e15a99 fix(native): Android shows "server not reachable" instead of a blank page
Capacitor injects its bridge on Android only into the server's origin once
server.url is set, so the local error page it switches to when the server
does not load had no bridge. It could neither retry nor change the server
and stayed blank.

TrekWebViewClient now catches a failed page load (a network error, or a 5xx
from a proxy while TREK restarts) and the app starts over on the bundled
address screen, where the bridge is present and the screen offers exactly
those two choices. The server config no longer carries errorPath.

Two traps on the way, both found in the emulator: a restart during launch
has to be a fresh task, since recreate() leaves the system splash lying over
the new page; and a restart shows no launch splash at all, because the
splash plugin cannot take it down again when the page hides it early.
2026-09-24 13:59:08 +02:00
Maurice b1479d939a fix(native): no overscroll stretch on Android
The UI asks for no overscroll effect with overscroll-behavior: none on html,
and Chrome honours it. The Android WebView draws the stretch on its root
scroller natively, though, and it scales the whole view, so the top bar and
the bottom dock were pulled along with the page. Found in the emulator.
2026-09-24 13:36:27 +02:00
Maurice 71913eb6e2 fix(native): remember the login by default inside the app
With the switch off the app got a session cookie, which dies with the app
process, so every time the phone unloaded TREK the user had to sign in
again. Found while running the Android build in the emulator.
2026-09-24 13:30:37 +02:00
Maurice 601ed66a38 test(mobile): the backup-code download revokes its URL after the shared delay
The mobile twin of the AccountTab test still expected the object URL to be
revoked in the same tick. Since both screens download through downloadBlob,
the revoke happens after its 100 ms grace period.
2026-09-24 13:22:49 +02:00
Maurice 9a69b092e8 feat(native): the small things that give a WebView away
- A short haptic tap on success, warning and error toasts, which is where
  every save and delete already reports. Info toasts stay silent.
- No "‹ › Done" strip above the iOS keyboard.
- No Safari preview when a link is long-pressed.
- The swipe from the left edge goes back on iOS, through the same history
  Android's back button walks.
2026-09-24 13:11:25 +02:00
Maurice be211c22ed fix(native): keep the iOS offline copy inside the app
loadSimulatedRequest went to the server URL without its trailing slash, and
Capacitor keeps a navigation in the WebView only when it starts with the
bridge's serverURL, slash included. The offline start page would have
opened in Safari instead. Also covers the platform helpers and the
hand-off API module.
2026-09-24 12:48:12 +02:00
Maurice 293b25466e feat(native): offline vector maps in the iOS app
In a browser the service worker answers MapLibre from the gl-map-offline
cache that glPrefetcher fills for trips marked for offline. The iOS app has
no service worker, so nothing read that cache there and the map went blank
without network.

A trek-offline:// protocol takes the service worker's place. Inside the iOS
app the MapLibre style URL is rewritten to it, the handler rewrites every
https URL inside the style and its TileJSON the same way, and then serves
documents network first and tiles, glyphs and sprites cache first. Outside
the iOS app nothing changes. The default OpenFreeMap basemap is covered;
Mapbox GL has no protocol hook and stays online-only in the app, as does a
custom raster template, whose opaque responses only a service worker can
store.
2026-09-24 12:47:07 +02:00
Maurice 1ba58e8b89 ci(native): build the Android and iOS shells on every change to them
Compiles the Swift side on a macOS runner so nobody needs a Mac to know it
still builds, and leaves a debug APK on every run to install on a phone.
Unsigned: signing and store uploads need secrets fork PRs do not get.
2026-09-24 12:43:06 +02:00
Maurice ae0d4e4520 feat(native): the UI knows when it runs inside the app
Everything the server's UI does differently inside the iOS and Android app
lives under src/native and is inert in a browser.

Sign-in: SSO and the passkey button go through the system browser
(signInThroughBrowser), because identity providers refuse embedded
browsers and WebAuthn needs a domain association no build can have for
every server. The browser end is the new /native-handoff page: it signs
the user in if needed and hands the session over only after an explicit
tap, so a link carrying somebody else's challenge cannot pass a session
on silently. The verifier is hashed with @noble/hashes because
crypto.subtle is missing on the plain-http servers many home networks
use. An OIDC-only install no longer bounces to the IdP inside the
WebView, and passkey management is hidden in the app.

Downloads: the WebView cannot save a blob, so downloadBlob, downloadFile
and openFile write to the app cache and open the share sheet. The eight
places that built their own download anchor now use downloadBlob, which
they should have anyway.

The rest: "Change server" on the login page and in the account settings,
the splash hides after the first frame, Android's back button walks
history before it leaves the app, the status bar follows the scheme, and
on iOS the start page is refreshed and every chunk of the build (listed
in the new asset-manifest.json) is pulled into WebKit's cache so the app
can start without network.
2026-09-24 12:42:34 +02:00
Maurice c5765bbf15 feat(native): the apps load the server's own UI
The apps ship only an address screen now. Once a server is picked, the
native side points the WebView at it: TrekBridgeViewController on iOS and a
config copy with server.url on Android. The UI then comes from that server,
in the version it runs and on its own origin, so the session cookie,
uploads, /ws and plugins work exactly as in a browser and no app release
has to keep pace with the servers out there.

The address screen (native-shell/, built into native-dist/) probes
/api/health/features through native HTTP, tries https before http when no
scheme was typed, and refuses servers without the nativeApp flag. It also
serves as the error page when the remembered server does not load: retry,
change server, or on iOS the stored copy of the server's start page.

TrekShell is the app's own plugin for all of that: remember, change and
reload the server, sign in through ASWebAuthenticationSession or a Custom
Tab, and on iOS keep and open the offline copy of the start page, because
WKWebView runs no service worker for a domain the app does not list.

Plugins the frame does not use are gone (push, camera, geolocation,
calendar, file transfer, in-app browser and more): the WebView covers the
camera and location itself, and push needs a relay before it is worth
shipping. With them go the push entitlement, the calendar usage strings
and the Firebase bits. The iOS scheme falls back to capacitor://, since
Capacitor drops an iosScheme of https, and the arm64 capability replaces
armv7.
2026-09-24 12:30:32 +02:00
Maurice 9832d12751 feat(auth): hand a browser sign-in over to the native app
The native apps show the server's own UI in a WebView, where neither SSO nor
passkeys can run. They sign in through the system browser instead and need a
way to carry that session back.

The app keeps a random verifier and sends only its sha256 into the browser.
Once signed in there, the browser trades that challenge for a one-time code
(cookie session only, so an API or MCP token cannot mint itself a browser
session), and the WebView redeems the code together with the verifier. The
code rides through a URL scheme any app could claim, so on its own it is
worthless; it is single use and good for a minute.

Also: /api/health/features reports nativeApp so the apps can refuse servers
that predate this, and hashed files under assets/ are served immutable,
which the iOS app needs to start offline without a service worker.
2026-09-24 12:19:10 +02:00
Maurice 14825df993 Merge remote-tracking branch 'origin/dev' into native-apps
# Conflicts:
#	client/package.json
#	package-lock.json
2026-09-24 11:53:50 +02:00
jubnl e12be0a185 Merge remote-tracking branch 'origin/dev' into feat/native-apps 2026-08-25 23:47:41 +02:00
jubnl 9f1f33213b chore(capacitor): base plugins 2026-08-24 00:23:51 +02:00
jubnl b2c029f02a Merge remote-tracking branch 'origin/dev' into feat/native-apps 2026-08-23 15:27:42 +02:00
jubnl 915a1f64e6 chore(capacitor): base files for capacitor 2026-08-22 22:52:25 +02:00
1301 changed files with 6317 additions and 65234 deletions
+1 -3
View File
@@ -140,9 +140,7 @@ jobs:
set -eu
# Derive version from git tags, no package.json dependency
# Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
+36 -39
View File
@@ -34,10 +34,7 @@ concurrency:
jobs:
android:
# Gated on a repository variable so the release can ship iOS alone while the
# Google Play account and its signing secrets are not yet in place. Set the
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-24.04
timeout-minutes: 40
environment: store
@@ -49,7 +46,7 @@ jobs:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-java@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
@@ -151,44 +148,44 @@ jobs:
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
- name: Distribution certificate
- name: Archive
working-directory: client/ios/App
env:
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
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"
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
# run on a device-less CI runner (it signs for development and then needs a
# development profile, which needs a registered device), so client/fastlane/Fastfile
# imports the stored distribution certificate and creates the App Store profile
# through the API key.
# The tag's message, past its subject line, is what testers read under
# "What to Test": one short line per change worth trying.
- name: TestFlight notes from the tag
if: startsWith(github.ref, 'refs/tags/')
run: |
git fetch --force --depth=1 origin "+${GITHUB_REF}:${GITHUB_REF}"
{
echo 'TESTFLIGHT_NOTES<<TREK_NOTES_END'
# A signed tag carries its signature at the end of the message.
git for-each-ref "$GITHUB_REF" --format='%(contents:body)' | sed '/-----BEGIN .*SIGNATURE-----/,$d'
echo 'TREK_NOTES_END'
} >> "$GITHUB_ENV"
- name: Build and upload to TestFlight
working-directory: client
# 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 }}
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MARKETING_VERSION: ${{ steps.version.outputs.version }}
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: fastlane ios beta
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 secrets
- name: Remove the API key
if: always()
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
+4 -149
View File
@@ -17,7 +17,6 @@ on:
- 'client/ios/**'
- 'client/native-shell/**'
- 'client/src/native/trekShell.ts'
- 'client/src/native/watch.ts'
- 'client/src/native/serverAddress.ts'
- 'client/capacitor.config.ts'
- 'client/vite.native.config.js'
@@ -60,7 +59,7 @@ jobs:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-java@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
@@ -81,7 +80,7 @@ jobs:
# unsigned: the upload key only exists in native-release.yml.
- name: Assemble debug and release APKs
working-directory: client/android
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
run: ./gradlew assembleDebug assembleRelease lintDebug --no-daemon
- uses: actions/upload-artifact@v6
with:
@@ -104,7 +103,7 @@ jobs:
xcodebuild -version
- name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist
- uses: actions/setup-node@v6
with:
@@ -121,23 +120,6 @@ jobs:
working-directory: client
run: npm run cap:sync -- ios
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
# No -sdk: the scheme builds the embedded watch app and its complications
# against the watchOS simulator SDK and the rest against the iOS one.
- name: Build for the simulator
working-directory: client/ios/App
run: |
@@ -145,133 +127,6 @@ jobs:
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
# dark, for a trip ahead, a trip under way and none, plus a home screen and
# a lock screen of them. Nobody has to install a build to see a widget change.
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
# where a change is looked at on a device; on every PR it only held the queue.
widget-previews:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- name: Select Xcode 26
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
sudo xcode-select -s "$XCODE"
- name: Render the widgets
working-directory: client/ios/WidgetPreviews
run: |
mkdir -p Sources/WidgetPreviews/Widgets
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \
../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
- uses: actions/upload-artifact@v6
with:
name: widget-previews
path: client/ios/WidgetPreviews/out
retention-days: 14
# The watch's half in the UI (client/src/native/watch.ts): the snapshot it
# builds and the ticks it takes back, typechecked and tested here as well,
# so a change to the bridge is checked next to the Swift that reads it.
watch-bridge:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck the client
working-directory: client
run: npm run typecheck
- name: Test the widget and watch snapshots
working-directory: client
run: npx vitest run src/native/watch.test.ts src/native/widgets.test.ts src/native/widgetExtras.test.ts
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
# every screen on the largest and the smallest watch, the main ones in every
# language, and the complications, as PNGs to look at without a watch.
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
watch-screens:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
- name: Select Xcode 26
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
sudo xcode-select -s "$XCODE"
# The project resolves the Capacitor packages from node_modules, even for
# a target that uses none of them.
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci --workspace=shared --workspace=client
- name: Make sure a watchOS simulator runtime is there
run: |
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
xcrun simctl list runtimes
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
- name: Build the watch app for the simulator
working-directory: client/ios/App
run: |
xcodebuild build \
-project App.xcodeproj \
-target TREKWatch \
-configuration Debug \
-sdk watchsimulator \
SYMROOT="$PWD/build" \
CODE_SIGNING_ALLOWED=NO
- name: Capture the screens
run: bash client/ios/scripts/watch-screens.sh client/ios/App/build/Debug-watchsimulator/TREKWatch.app "$PWD/watch-screens"
- uses: actions/upload-artifact@v6
if: always()
with:
name: watch-screens
path: watch-screens
retention-days: 14
+2 -4
View File
@@ -1236,12 +1236,10 @@ jobs:
bin="$RUNNER_TEMP/bin"
mkdir -p "$bin"
cd "$RUNNER_TEMP"
# GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
tar -xzf kubeconform.tgz -C "$bin" kubeconform
echo "$bin" >> "$GITHUB_PATH"
-3
View File
@@ -70,10 +70,7 @@ COPY server/package.json ./server/
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping.
# The upgrade takes the Debian security fixes the base image was built before
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
+1 -1
View File
@@ -56,5 +56,5 @@ See `values.yaml` for more options.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands `/app/data` and `/app/uploads` to `node` (uid 1000) if the directory or anything directly inside it is not owned by `node` yet, and drops to `node`. Deeper files are not checked. Only the owning user is changed (plus a `root` group), so a different `fsGroup` stays in place. A volume that refuses `chown` (NFS with `root_squash`) logs a warning instead; set `runAsUser: 1000` then. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.1.2
version: 5.1.0
description: Minimal Helm chart for TREK app
appVersion: "5.1.2"
appVersion: "5.1.0"
-3
View File
@@ -127,9 +127,6 @@ data:
{{- if .Values.env.TREK_VALHALLA_URL }}
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
{{- end }}
{{- if .Values.env.TREK_WALLET_SIGNER_URL }}
TREK_WALLET_SIGNER_URL: {{ .Values.env.TREK_WALLET_SIGNER_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
-4
View File
@@ -159,10 +159,6 @@ env:
# TREK_VALHALLA_URL: ""
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
# TREK_WALLET_SIGNER_URL: ""
# Signs the Apple Wallet pass of a booking when someone taps "Add to Apple Wallet" in the iOS app.
# Only the pass's texts go there, once per tap. Never asked with TREK_SERVICES=off.
# Defaults to https://push.liketrek.com/v1/wallet/pass.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
-3
View File
@@ -106,6 +106,3 @@ app/src/main/assets/public
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# Kotlin compiler caches
.kotlin/
-50
View File
@@ -1,6 +1,4 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
android {
namespace = "com.liketrek.trek"
@@ -34,25 +32,6 @@ android {
}
}
}
// java.time in the widgets and the travel-day notification, back to Android 7.
compileOptions {
coreLibraryDesugaringEnabled true
}
buildFeatures {
compose = true
buildConfig = true
}
// The app's languages, for the system's per-app language setting (Android 13+):
// generated from the values-* folders, so a language added there is offered too.
androidResources {
generateLocaleConfig = true
}
testOptions {
unitTests {
includeAndroidResources = true
returnDefaultValues = true
}
}
buildTypes {
release {
if (project.hasProperty('trekKeystore')) {
@@ -77,40 +56,11 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.core:core-ktx:$androidxCoreVersion"
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
// Home-screen widgets.
implementation "androidx.glance:glance-appwidget:$glanceVersion"
implementation "androidx.glance:glance-material3:$glanceVersion"
// The share sheet and the widget's trip picker.
implementation platform("androidx.compose:compose-bom:$composeBomVersion")
implementation "androidx.compose.material3:material3"
implementation "androidx.compose.material:material-icons-extended"
implementation "androidx.compose.ui:ui-tooling-preview"
debugImplementation "androidx.compose.ui:ui-tooling"
implementation "androidx.activity:activity-compose:$androidxActivityVersion"
// A foldable's posture and hinge, and the window's size, for the UI.
implementation "androidx.window:window:1.4.0"
// Ranks the trip shortcuts in the share sheet on Android 10 and earlier.
implementation "androidx.sharetarget:sharetarget:1.2.0"
// TREK Cloud subscriptions through Google Play, the counterpart of StoreKit.
implementation "com.android.billingclient:billing:$playBillingVersion"
// Push through liketrek's relay. No google-services plugin: Firebase reads
// its project from res/values/firebase.xml, the strings the plugin would generate.
implementation "com.google.firebase:firebase-messaging:24.1.2"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
testImplementation "org.json:json:20250517"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
}
}
@@ -9,9 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-action-sheet')
implementation project(':capacitor-app')
implementation project(':capacitor-dialog')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug builds only: the widget gallery (debug/WidgetGalleryActivity.kt). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".debug.WidgetGalleryActivity"
android:theme="@style/AppTheme.NoActionBar"
android:exported="true" />
</application>
</manifest>
@@ -1,125 +0,0 @@
package com.liketrek.trek.debug
import android.app.Activity
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import com.liketrek.trek.tripday.TripDayNotification
import com.liketrek.trek.widget.AtlasWidget
import com.liketrek.trek.widget.BookingsWidget
import com.liketrek.trek.widget.CollabWidget
import com.liketrek.trek.widget.PackingWidget
import com.liketrek.trek.widget.TodayWidget
import com.liketrek.trek.widget.TodosWidget
import com.liketrek.trek.widget.TrekSizes
import com.liketrek.trek.widget.TrekWidget
import com.liketrek.trek.widget.TrekWidgetTheme
import com.liketrek.trek.widget.TripWidget
import com.liketrek.trek.widget.WidgetSamples
import com.liketrek.trek.widget.WidgetStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Debug builds only: every widget at every size, drawn by the real Glance code
* into real RemoteViews, so they can be looked at and screenshot without placing
* each on a home screen.
*
* adb shell am start -n com.liketrek.trek/.debug.WidgetGalleryActivity --es widget trip --ez running true --ez dark false
*
* --es seed sample also writes the sample trip as the app's snapshot, so the
* placed widgets, the share sheet and the shortcuts have a trip to show; and
* --ez tripday true posts the travel day's Live Update from the sample.
*/
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
class WidgetGalleryActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val running = intent.getBooleanExtra("running", true)
val dark = intent.getBooleanExtra("dark", false)
val only = intent.getStringExtra("widget")
if (intent.getStringExtra("seed") == "sample") seed()
if (intent.getBooleanExtra("tripday", false)) {
TripDayNotification.show(
this,
TripDayNotification.State(
"Autumn in Kyoto", "/trips/1", "Day 3 of 9", "Kiyomizu-dera", "13:30", 810, 2, 5,
listOf("#D6273B", "#E8A13A", "#2FA37A", "#4A7DDB", "#8E6CD9"),
),
)
}
val config = Configuration(resources.configuration).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
val themed = createConfigurationContext(config)
val list = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(px(16), px(if (intent.getBooleanExtra("bare", false)) 96 else 32), px(16), px(32))
val bg = intent.getStringExtra("bg")
setBackgroundColor(if (bg != null) Color.parseColor("#$bg") else if (dark) Color.rgb(28, 27, 31) else Color.rgb(222, 226, 236))
}
setContentView(ScrollView(this).apply { addView(list) })
val widgets: List<Pair<String, TrekWidget>> = listOf(
"trip" to TripWidget(),
"today" to TodayWidget(),
"bookings" to BookingsWidget(),
"todos" to TodosWidget(),
"packing" to PackingWidget(),
"atlas" to AtlasWidget(),
"collab" to CollabWidget(),
).filter { only == null || it.first == only }
// --ez store true draws what the app really wrote, instead of the sample trip.
val snapshot = if (intent.getBooleanExtra("store", false)) WidgetStore.load(this) else WidgetSamples.snapshot(running)
CoroutineScope(Dispatchers.Main).launch {
for ((name, widget) in widgets) {
if (!intent.getBooleanExtra("bare", false)) list.addView(TextView(this@WidgetGalleryActivity).apply {
text = name
setTextColor(if (dark) Color.WHITE else Color.DKGRAY)
setPadding(0, px(12), 0, px(6))
})
val onlySize = intent.getStringExtra("size")
val sizes = listOf("small" to TrekSizes.SMALL, "wide" to TrekSizes.WIDE, "large" to TrekSizes.LARGE)
for ((sizeName, size) in sizes) {
if (onlySize != null && onlySize != sizeName) continue
val result = GlanceRemoteViews().compose(themed, size) {
TrekWidgetTheme.Themed { widget.Content(snapshot) }
}
val host = FrameLayout(this@WidgetGalleryActivity)
host.addView(result.remoteViews.apply(themed, host), FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
list.addView(host, LinearLayout.LayoutParams(px(size.width.value), px(size.height.value)).apply { bottomMargin = px(12) })
}
}
}
}
private fun seed() {
WidgetStore.write(this, "snapshot", WidgetSamples.json(running = true))
WidgetStore.write(this, "trip.1", WidgetSamples.json(running = true))
WidgetStore.write(this, "trip.2", WidgetSamples.json(running = false).replace("Autumn in Kyoto", "Lisbon long weekend"))
WidgetStore.write(
this,
"trips",
"""[{"id":1,"title":"Autumn in Kyoto","start":"${java.time.LocalDate.now().minusDays(2)}","end":"${java.time.LocalDate.now().plusDays(6)}"},
{"id":2,"title":"Lisbon long weekend","start":"${java.time.LocalDate.now().plusDays(12)}","end":"${java.time.LocalDate.now().plusDays(15)}"},
{"id":3,"title":"Norway by train","start":"2026-06-02","end":"2026-06-12"}]""",
)
com.liketrek.trek.widget.TrekWidgetUpdater.refreshAll(this)
com.liketrek.trek.share.TrekShortcuts.publish(this)
}
private fun px(dp: Float): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()
private fun px(dp: Int): Int = px(dp.toFloat())
}
+2 -146
View File
@@ -9,33 +9,21 @@
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:resizeableActivity="true"
android:theme="@style/AppTheme">
<!-- Every window shape a tablet, a foldable, ChromeOS or desktop windowing
offers. The UI lays itself out by the window's size (hooks/sizeClass.ts),
so the activity takes size, fold and orientation changes itself
(configChanges) instead of restarting and losing the page. -->
<meta-data android:name="android.supports_size_changes" android:value="true" />
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout|uiMode|navigation|density|fontScale|touchscreen|colorMode|fontWeightAdjustment"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="fullUser"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<!-- The trips as launcher and direct-share shortcuts (TrekShortcuts). -->
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links.
The scheme is @string/custom_url_scheme (com.liketrek.trek).
@@ -62,131 +50,6 @@
</activity>
<!-- "TREK" in the share sheet and "Open with TREK": a booking document, a
mail, a page or a link, into a trip (share/ShareActivity.kt). -->
<activity
android:name=".share.ShareActivity"
android:theme="@style/AppTheme.Sheet"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:taskAffinity="com.liketrek.trek.share"
android:launchMode="standard"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/vnd.apple.pkpass" />
<data android:mimeType="message/rfc822" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/html" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<!-- content:// only: a file:// path would be read with TREK's own rights. -->
<data android:scheme="content" />
<data android:mimeType="application/pdf" />
<data android:mimeType="text/calendar" />
<data android:mimeType="application/vnd.apple.pkpass" />
<data android:mimeType="message/rfc822" />
</intent-filter>
<!-- Lets the share sheet rank the trip shortcuts for this target. -->
<meta-data android:name="android.service.chooser.chooser_target_service" android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
<!-- "Choose trip" for a placed widget. -->
<activity
android:name=".widget.WidgetConfigActivity"
android:theme="@style/AppTheme.Native"
android:excludeFromRecents="true"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- The home-screen widgets (widget/TrekWidgets.kt). -->
<receiver
android:name=".widget.TripWidgetReceiver"
android:label="@string/widget_trip_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_trip" />
</receiver>
<receiver
android:name=".widget.TodayWidgetReceiver"
android:label="@string/widget_today_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_today" />
</receiver>
<receiver
android:name=".widget.BookingsWidgetReceiver"
android:label="@string/widget_bookings_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bookings" />
</receiver>
<receiver
android:name=".widget.TodosWidgetReceiver"
android:label="@string/widget_todos_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_todos" />
</receiver>
<receiver
android:name=".widget.PackingWidgetReceiver"
android:label="@string/widget_packing_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_packing" />
</receiver>
<receiver
android:name=".widget.AtlasWidgetReceiver"
android:label="@string/widget_atlas_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_atlas" />
</receiver>
<receiver
android:name=".widget.CollabWidgetReceiver"
android:label="@string/widget_collab_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_collab" />
</receiver>
<!-- Push: the relay's data messages, decrypted and shown here. -->
<service
android:name=".TrekPushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -213,12 +76,5 @@
Play hide the app from tablets and other GPS-less devices. -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Android 13 and later ask before an app may show notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Android 16: the travel day's Live Update may be promoted to the top of the shade. -->
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Tablets, Chromebooks and TVs without these still get the app on Play. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.screen.portrait" android:required="false" />
</manifest>
@@ -2,25 +2,14 @@ package com.liketrek.trek;
import android.content.Intent;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.KeyboardShortcutGroup;
import android.view.Menu;
import android.view.View;
import com.getcapacitor.BridgeActivity;
import com.liketrek.trek.screen.TrekKeyCommands;
import com.liketrek.trek.tiles.TrekTileStore;
import com.liketrek.trek.widget.TrekWidgetUpdater;
import java.util.List;
public class MainActivity extends BridgeActivity {
/** The share sheet (share/ShareActivity.kt) left an item for the import. */
public static final String ACTION_SHARE_HANDOFF = "com.liketrek.trek.SHARE_HANDOFF";
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(TrekShellPlugin.class);
TrekTileStore.INSTANCE.init(this);
// With a server picked, the WebView loads that server instead of the
// bundled address screen, so the UI arrives in the version the server
// runs and on its own origin. BridgeActivity reads `config` while it
@@ -36,34 +25,7 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this);
}
/**
* A hardware keyboard's shortcut runs the UI's command (TrekKeyCommands),
* wherever the focus is; everything else goes on to the WebView as usual.
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (TrekKeyCommands.INSTANCE.dispatch(event)) return true;
return super.dispatchKeyEvent(event);
}
/** The UI's shortcuts in the system's list (Meta + /), under their names. */
@Override
public void onProvideKeyboardShortcuts(List<KeyboardShortcutGroup> data, Menu menu, int deviceId) {
super.onProvideKeyboardShortcuts(data, menu, deviceId);
KeyboardShortcutGroup group = TrekKeyCommands.INSTANCE.shortcutGroup(getString(R.string.app_name));
if (group != null) data.add(group);
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
TrekSharedFiles.receive(this, intent);
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen));
}
private boolean leavingServer;
@@ -1,57 +0,0 @@
package com.liketrek.trek;
import java.net.URI;
import java.util.Locale;
/**
* A sign-in page in front of the server that the address screen never saw.
* A proxy can let the app's check through (an Authelia or Authentik rule that
* bypasses /api) and still send the page itself to its sign-in. Handed to the
* browser, that sign-in leaves its session there and the app stays blank on
* every launch. So a redirect away from the server, while the server's first
* page is still loading, is taken as the proxy's sign-in: it loads in the web
* view and its host is kept, the way the address screen keeps the ones it saw.
* A link the user follows is no redirect, and once a page of the server has
* shown, nothing is learned any more.
*/
final class SignInRedirect {
private SignInRedirect() {}
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
URI from;
URI to;
try {
from = new URI(server);
to = new URI(target);
} catch (Exception e) {
return null;
}
String fromHost = lower(from.getHost());
String toHost = lower(to.getHost());
String toScheme = lower(to.getScheme());
if (fromHost == null || toHost == null) return null;
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
// Never from https down to plain http: the sign-in would send a password in the clear.
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
if (toHost.equals(fromHost)) return null;
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
}
/** Whether `url` is on the server's host, where the app's own pages come from. */
static boolean onServer(String server, String url) {
if (server == null || url == null) return false;
try {
String serverHost = lower(new URI(server).getHost());
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
} catch (Exception e) {
return false;
}
}
private static String lower(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
}
@@ -1,139 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.SharedPreferences;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* The device's Web Push key pair (P-256) and auth secret (RFC 8291). The server
* encrypts every notification for these, and TrekPushService decrypts with
* them. They are kept in the app's preferences, sealed with an AES key that
* never leaves the Android Keystore, so a copy of the preferences alone opens
* nothing.
*/
final class TrekPushKeys {
private static final String PREFS = "trek_push";
private static final String WRAP_ALIAS = "trek_push_wrap";
private static final String PRIVATE = "private";
private static final String PUBLIC = "public";
private static final String AUTH = "auth";
final ECPrivateKey privateKey;
/** Uncompressed point, 65 bytes, as the server reads p256dh. */
final byte[] publicKey;
final byte[] auth;
TrekPushKeys(ECPrivateKey privateKey, byte[] publicKey, byte[] auth) {
this.privateKey = privateKey;
this.publicKey = publicKey;
this.auth = auth;
}
/** The stored keys, or null when the app never subscribed. */
static synchronized TrekPushKeys load(Context context) throws Exception {
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String sealedPrivate = prefs.getString(PRIVATE, null);
String publicKey = prefs.getString(PUBLIC, null);
String sealedAuth = prefs.getString(AUTH, null);
if (sealedPrivate == null || publicKey == null || sealedAuth == null) return null;
byte[] pkcs8 = unseal(sealedPrivate);
ECPrivateKey key = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
return new TrekPushKeys(key, Base64.decode(publicKey, Base64.NO_WRAP), unseal(sealedAuth));
}
static synchronized TrekPushKeys loadOrCreate(Context context) throws Exception {
TrekPushKeys existing;
try {
existing = load(context);
} catch (Exception unreadable) {
// The Keystore key is gone (backup restored to another device, data
// cleared): start over, the next subscription carries the new keys.
existing = null;
}
if (existing != null) return existing;
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair pair = generator.generateKeyPair();
byte[] publicKey = uncompressed((ECPublicKey) pair.getPublic());
byte[] auth = new byte[16];
new SecureRandom().nextBytes(auth);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(PRIVATE, seal(pair.getPrivate().getEncoded()))
.putString(PUBLIC, Base64.encodeToString(publicKey, Base64.NO_WRAP))
.putString(AUTH, seal(auth))
.apply();
return new TrekPushKeys((ECPrivateKey) pair.getPrivate(), publicKey, auth);
}
static String base64url(byte[] data) {
return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
}
static byte[] uncompressed(ECPublicKey key) {
byte[] out = new byte[65];
out[0] = 0x04;
copyFixed(key.getW().getAffineX(), out, 1);
copyFixed(key.getW().getAffineY(), out, 33);
return out;
}
/** A coordinate as exactly 32 bytes: BigInteger adds a sign byte or drops leading zeros. */
private static void copyFixed(BigInteger value, byte[] out, int offset) {
byte[] raw = value.toByteArray();
int start = Math.max(0, raw.length - 32);
int length = raw.length - start;
System.arraycopy(raw, start, out, offset + 32 - length, length);
}
private static SecretKey wrapKey() throws Exception {
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
store.load(null);
if (store.containsAlias(WRAP_ALIAS)) return ((KeyStore.SecretKeyEntry) store.getEntry(WRAP_ALIAS, null)).getSecretKey();
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
generator.init(
new KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
);
return generator.generateKey();
}
private static String seal(byte[] plain) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, wrapKey());
byte[] iv = cipher.getIV();
byte[] sealed = cipher.doFinal(plain);
byte[] out = new byte[iv.length + sealed.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(sealed, 0, out, iv.length, sealed.length);
return Base64.encodeToString(out, Base64.NO_WRAP);
}
private static byte[] unseal(String value) throws Exception {
byte[] in = Base64.decode(value, Base64.NO_WRAP);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), new GCMParameterSpec(128, in, 0, 12));
return cipher.doFinal(in, 12, in.length - 12);
}
}
@@ -1,160 +0,0 @@
package com.liketrek.trek;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.nio.charset.StandardCharsets;
import org.json.JSONObject;
/**
* Receives the relay's data messages ({enc}: the server's encrypted Web Push
* record), decrypts them with the device's keys and shows the notification.
* Without the keys, or when the record does not open, it shows the same
* neutral text the iOS extension falls back to, never the ciphertext.
*/
public class TrekPushService extends FirebaseMessagingService {
static final String CHANNEL = "trek";
static final String EXTRA_URL = "trek.pushUrl";
@Override
public void onMessageReceived(@NonNull RemoteMessage message) {
String enc = message.getData().get("enc");
if (enc == null) return;
String title = getString(R.string.push_fallback_title);
String body = getString(R.string.push_fallback_body);
String url = null;
String tag = null;
String event = null;
try {
TrekPushKeys keys = TrekPushKeys.load(this);
if (keys != null) {
byte[] plain = TrekWebPush.decrypt(Base64.decode(enc, Base64.DEFAULT), keys);
JSONObject payload = new JSONObject(new String(plain, StandardCharsets.UTF_8));
if (!payload.optString("title").isEmpty()) title = payload.optString("title");
body = payload.optString("body", body);
url = payload.optString("url", null);
tag = payload.optString("tag", null);
event = payload.optString("event", null);
}
} catch (Exception unreadable) {
// Shown with the neutral text: the tap still opens the app.
}
show(this, title, body, url, tag, event);
}
/** A new token means a new endpoint; the UI re-registers on its next start (resyncPushSubscription). */
@Override
public void onNewToken(@NonNull String token) {}
/** The channels, so each kind can be silenced, raised or kept on its own in the system settings. */
static final String CHANNEL_CHAT = "chat";
static final String CHANNEL_TRIPS = "trips";
static final String CHANNEL_INVITES = "invites";
/**
* The channel of a notification by the event the server names (PushPayload.event,
* notification-events.ts). A server too old to send one lands in the general channel.
*/
static String channelFor(String event) {
if (event == null) return CHANNEL;
switch (event) {
case "collab_message":
return CHANNEL_CHAT;
case "booking_change":
case "trip_reminder":
case "todo_due":
case "packing_tagged":
return CHANNEL_TRIPS;
case "trip_invite":
case "vacay_invite":
case "vacay_share":
case "collection_invite":
case "photos_shared":
return CHANNEL_INVITES;
default:
return CHANNEL;
}
}
static void ensureChannel(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager manager = context.getSystemService(NotificationManager.class);
if (manager == null) return;
create(manager, CHANNEL_CHAT, context.getString(R.string.push_channel_chat), NotificationManager.IMPORTANCE_HIGH);
create(manager, CHANNEL_TRIPS, context.getString(R.string.push_channel_trips), NotificationManager.IMPORTANCE_DEFAULT);
create(manager, CHANNEL_INVITES, context.getString(R.string.push_channel_invites), NotificationManager.IMPORTANCE_DEFAULT);
create(manager, CHANNEL, context.getString(R.string.push_channel_name), NotificationManager.IMPORTANCE_DEFAULT);
com.liketrek.trek.tripday.TripDayNotification.INSTANCE.ensureChannel(context);
}
@androidx.annotation.RequiresApi(Build.VERSION_CODES.O)
private static void create(NotificationManager manager, String id, String name, int importance) {
NotificationChannel existing = manager.getNotificationChannel(id);
if (existing != null) {
// Only the name follows the app's language; what the user set stays theirs.
if (!name.contentEquals(existing.getName())) {
existing.setName(name);
manager.createNotificationChannel(existing);
}
return;
}
manager.createNotificationChannel(new NotificationChannel(id, name, importance));
}
private static void show(Context context, String title, String body, String url, String tag, String event) {
ensureChannel(context);
Intent open = new Intent(context, MainActivity.class)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
if (url != null) open.putExtra(EXTRA_URL, url);
int id = tag != null ? tag.hashCode() : (int) (System.currentTimeMillis() & 0x7fffffff);
PendingIntent tap = PendingIntent.getActivity(
context,
id,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
String channel = channelFor(event);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_stat_trek)
.setContentTitle(title)
.setContentText(body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setColor(ContextCompat.getColor(context, R.color.notification_accent))
.setCategory(CHANNEL_CHAT.equals(channel) ? NotificationCompat.CATEGORY_MESSAGE : NotificationCompat.CATEGORY_EVENT)
// One stack per kind in the shade, rather than a loose pile.
.setGroup("trek." + channel)
.setContentIntent(tap);
if (Build.VERSION.SDK_INT >= 33
&& ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return;
}
try {
NotificationManagerCompat.from(context).notify(tag, id, builder.build());
// The stack's head: the system folds the group under it from two notifications on.
NotificationCompat.Builder summary = new NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_stat_trek)
.setColor(ContextCompat.getColor(context, R.color.notification_accent))
.setGroup("trek." + channel)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setAutoCancel(true);
NotificationManagerCompat.from(context).notify("trek.summary", channel.hashCode(), summary.build());
} catch (SecurityException notAllowed) {
// Notifications switched off for the app since it subscribed.
}
}
}
@@ -10,10 +10,6 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.json.JSONException;
import org.json.JSONObject;
@@ -22,80 +18,26 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart";
private static final String KEY_WELCOME = "welcome";
private static final String CONFIG_DIR = "trek-server-config";
private TrekServerStore() {}
/**
* The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
* server's first page opens its welcome page with it once, which signs the
* buyer in (TrekWebViewClient). Kept only until the restart has loaded.
*/
static void setWelcome(Context context, String code) {
SharedPreferences.Editor editor = prefs(context).edit();
if (code == null) editor.remove(KEY_WELCOME);
else editor.putString(KEY_WELCOME, code);
editor.commit();
}
/** The welcome page for the pending code, handed out once; null when there is none. */
static String takeWelcomeUrl(Context context) {
String code = prefs(context).getString(KEY_WELCOME, null);
String server = getUrl(context);
if (code == null) return null;
prefs(context).edit().remove(KEY_WELCOME).commit();
if (server == null) return null;
return (server.endsWith("/") ? server : server + "/") + "api/managed/welcome#" + code;
}
static String getUrl(Context context) {
return prefs(context).getString(KEY_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) {
setServer(context, url, null);
}
static void setServer(Context context, String url, Set<String> signInHosts) {
SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
}
if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS);
} else {
Set<String> lower = new HashSet<>();
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
}
editor.commit();
}
/**
* The hosts of a sign-in page that stands in front of the server (a proxy
* such as Cloudflare Access or Pangolin), as the address screen found them.
* Only these may load inside the web view besides the server itself, so
* the proxy's session lands where the app can use it (TrekWebViewClient).
*/
static Set<String> getSignInHosts(Context context) {
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
}
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
static void addSignInHost(Context context, String host) {
Set<String> hosts = new HashSet<>(getSignInHosts(context));
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
}
/** Start the next launch on the bundled address screen, even with a server remembered. */
static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
@@ -1,105 +0,0 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Base64;
import com.getcapacitor.JSObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
/**
* Booking documents shared to TREK (open with / share), held until the UI takes
* them (TrekShell.takeSharedFile) and announced if it is already running.
*/
final class TrekSharedFiles {
/** Above this the file is not a booking confirmation. */
private static final int MAX_BYTES = 15 * 1024 * 1024;
interface Listener {
void onFile(JSObject file);
}
private static JSObject pending;
private static Listener listener;
private TrekSharedFiles() {}
static void setListener(Listener l) {
listener = l;
}
static JSObject take() {
JSObject file = pending;
pending = null;
return file;
}
static void receive(Context context, Intent intent) {
if (intent == null) return;
if (MainActivity.ACTION_SHARE_HANDOFF.equals(intent.getAction())) {
receiveHandoff(context);
return;
}
Uri uri = null;
// A view intent also brings sign-in callbacks and widget links; only a file is a document.
if (Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null
&& "content".equals(intent.getData().getScheme())) {
uri = intent.getData();
}
else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
// content:// only: a file:// path would be read with TREK's own rights.
if (uri == null || !"content".equals(uri.getScheme())) return;
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
if (in == null) return;
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
if (out.size() > MAX_BYTES) return;
}
JSObject file = new JSObject();
file.put("name", displayName(context, uri));
String type = context.getContentResolver().getType(uri);
file.put("mimeType", type == null ? intent.getType() : type);
file.put("data", Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP));
pending = file;
if (listener != null) listener.onFile(file);
} catch (Exception ignored) {
// Unreadable or gone: nothing to import.
}
}
/** The item the share sheet left (share/ShareHandoff.kt), with the trip picked for it there. */
static void receiveHandoff(Context context) {
kotlin.Pair<org.json.JSONObject, byte[]> item = com.liketrek.trek.share.ShareHandoff.INSTANCE.take(context);
if (item == null || item.getSecond().length > MAX_BYTES) return;
org.json.JSONObject meta = item.getFirst();
JSObject file = new JSObject();
file.put("name", meta.optString("name", "booking"));
file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
// A page or a link: where it came from, so the UI can keep it among the trip's links.
String url = meta.optString("url", "");
if (url.startsWith("https://") || url.startsWith("http://")) {
file.put("url", url);
file.put("title", meta.optString("title", url));
}
pending = file;
if (listener != null) listener.onFile(file);
}
private static String displayName(Context context, Uri uri) {
try (Cursor cursor = context.getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) return cursor.getString(0);
} catch (Exception ignored) {
// Fall through to the path.
}
String last = uri.getLastPathSegment();
return last == null ? "booking" : last;
}
}
@@ -1,44 +1,21 @@
package com.liketrek.trek;
import android.Manifest;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import com.liketrek.trek.clientcert.ClientCertActions;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@CapacitorPlugin(
name = "TrekShell",
permissions = {
@Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = "notifications"),
@Permission(strings = { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION }, alias = "location"),
}
)
@CapacitorPlugin(name = "TrekShell")
public class TrekShellPlugin extends Plugin {
private static final String TAG = "TrekShell";
/** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600;
@@ -47,226 +24,6 @@ public class TrekShellPlugin extends Plugin {
private boolean authBrowserOpen;
private final Handler handler = new Handler(Looper.getMainLooper());
/**
* What this binary offers the server's UI, raised with every native feature
* an older binary lacks. The UI asks before it calls (native/capabilities.ts),
* and a server can require a minimum (minNativeShellApi in /api/health/features).
*/
static final int SHELL_API = 2;
/** The native features by name, so the UI can turn each on by itself. */
static final String[] FEATURES = {
"capabilities",
"biometricLock",
"sharedFiles",
"nativePush",
"geolocation",
"tileCache",
"widgets",
"widgetTrips",
"liveActivity",
"shareSheet",
"playBilling",
"googleWallet",
"keyCommands",
"windowLayout",
"clientCertificate",
};
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
static final String PUSH_RELAY = "https://push.liketrek.com/v1/fcm/";
/** The notification the app was started from, until the UI asks for it. */
private String openedPushUrl;
@PluginMethod
public void getCapabilities(PluginCall call) {
JSObject result = new JSObject();
result.put("shellApi", SHELL_API);
result.put("platform", "android");
String version = "";
String build = "";
try {
android.content.pm.PackageInfo info = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
version = info.versionName == null ? "" : info.versionName;
build = String.valueOf(androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(info));
} catch (android.content.pm.PackageManager.NameNotFoundException ignored) {
// Our own package is always there; the fields simply stay empty.
}
result.put("appVersion", version);
result.put("build", build);
com.getcapacitor.JSArray features = new com.getcapacitor.JSArray();
for (String feature : FEATURES) features.put(feature);
result.put("features", features);
call.resolve(result);
}
// ---- Shared booking documents ----
@Override
public void load() {
TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file));
// A reloaded page starts without a stream; whatever the old one ran is stopped.
if (location != null) location.stop();
locationActive = false;
// A pending purchase that cleared while the shell runs: redeemed like a restore.
com.liketrek.trek.billing.TrekBilling.INSTANCE.setOnTransaction(receipt -> {
notifyListeners("storeTransaction", receipt);
return kotlin.Unit.INSTANCE;
});
com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.setOnPress(id -> {
JSObject pressed = new JSObject();
pressed.put("id", id);
notifyListeners("keyCommand", pressed);
return kotlin.Unit.INSTANCE;
});
if (getActivity() != null) {
com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.observe(getActivity(), layout -> {
notifyListeners("windowLayout", layout);
return kotlin.Unit.INSTANCE;
});
}
if (getActivity() != null && getActivity().getIntent() != null) {
openedPushUrl = getActivity().getIntent().getStringExtra(TrekPushService.EXTRA_URL);
getActivity().getIntent().removeExtra(TrekPushService.EXTRA_URL);
}
}
// ---- Push ----
/**
* Permission, the FCM token and the device keys, as the Web Push subscription
* the server stores (native/push.ts). Rejects with DENIED when the user
* refuses notifications, as on iOS.
*/
@PluginMethod
public void registerPush(PluginCall call) {
if (Build.VERSION.SDK_INT >= 33 && getPermissionState("notifications") != PermissionState.GRANTED) {
requestPermissionForAlias("notifications", call, "pushPermissionAnswered");
return;
}
subscribePush(call);
}
@PermissionCallback
private void pushPermissionAnswered(PluginCall call) {
if (getPermissionState("notifications") != PermissionState.GRANTED) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
subscribePush(call);
}
private void subscribePush(PluginCall call) {
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
call.reject("Notifications are not allowed", "DENIED");
return;
}
TrekPushService.ensureChannel(getContext());
final TrekPushKeys keys;
try {
keys = TrekPushKeys.loadOrCreate(getContext());
} catch (Exception e) {
call.reject("Could not create the push keys", e);
return;
}
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
if (!task.isSuccessful() || task.getResult() == null) {
call.reject("Could not get a push token", task.getException());
return;
}
JSObject subscription = new JSObject();
subscription.put("endpoint", PUSH_RELAY + task.getResult());
JSObject keyObject = new JSObject();
keyObject.put("p256dh", TrekPushKeys.base64url(keys.publicKey));
keyObject.put("auth", TrekPushKeys.base64url(keys.auth));
subscription.put("keys", keyObject);
call.resolve(subscription);
});
}
@PluginMethod
public void takeOpenedPush(PluginCall call) {
JSObject result = new JSObject();
result.put("url", openedPushUrl == null ? JSONObject.NULL : openedPushUrl);
openedPushUrl = null;
call.resolve(result);
}
/** The document shared to TREK before the UI was there to hear of it. */
@PluginMethod
public void takeSharedFile(PluginCall call) {
JSObject result = new JSObject();
JSObject file = TrekSharedFiles.take();
result.put("file", file == null ? JSONObject.NULL : file);
call.resolve(result);
}
// ---- App lock ----
private static final int AUTHENTICATORS =
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
/** Which biometry the device offers for the app lock, if any. */
@PluginMethod
public void biometryInfo(PluginCall call) {
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
JSObject result = new JSObject();
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
result.put("type", "biometric");
call.resolve(result);
}
/** The system biometric prompt with the screen lock as the way out. */
@PluginMethod
public void authenticateBiometric(PluginCall call) {
String reason = call.getString("reason", "Unlock TREK");
getActivity().runOnUiThread(() -> {
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
activity,
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
JSObject ok = new JSObject();
ok.put("ok", true);
call.resolve(ok);
}
@Override
public void onAuthenticationError(int code, CharSequence message) {
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
}
}
);
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
.setTitle(reason)
.setAllowedAuthenticators(AUTHENTICATORS)
.build();
prompt.authenticate(info);
});
}
/** While on, the recent-apps screen shows no snapshot of the app. */
@PluginMethod
public void setPrivacyCover(PluginCall call) {
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
getActivity().runOnUiThread(() -> {
if (android.os.Build.VERSION.SDK_INT >= 33) {
getActivity().setRecentsScreenshotEnabled(!enabled);
} else if (enabled) {
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
} else {
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
}
});
call.resolve();
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
@@ -283,26 +40,11 @@ public class TrekShellPlugin extends Plugin {
call.reject("A http(s) URL is required");
return;
}
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
String welcome = call.getString("welcome");
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
TrekServerStore.setUrl(getContext(), url);
call.resolve();
restart();
}
/** The sign-in hosts the address screen passes along, plain host names only. */
private static Set<String> signInHostsOf(PluginCall call) {
Set<String> hosts = new HashSet<>();
JSArray list = call.getArray("signInHosts");
if (list == null) return hosts;
for (int i = 0; i < list.length(); i++) {
String host = list.optString(i, "");
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
}
return hosts;
}
@PluginMethod
public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null);
@@ -318,8 +60,6 @@ public class TrekShellPlugin extends Plugin {
/** The server URL is part of the bridge's configuration, so it takes a new activity. */
private void restart() {
locationActive = false;
if (location != null) location.stop();
TrekServerStore.markRestart(getContext());
getActivity().runOnUiThread(() -> getActivity().recreate());
}
@@ -341,45 +81,12 @@ public class TrekShellPlugin extends Plugin {
// A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml.
Uri target = Uri.parse(url);
getActivity().runOnUiThread(() -> openSignIn(target));
}
/**
* Opens the sign-in in a Custom Tab, or in the browser itself where no
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
* launch from the bridge's thread without a word, and the sign-in then
* looked cancelled. A browser that cannot open at all is reported as such.
*/
private void openSignIn(Uri target) {
try {
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
return;
} catch (RuntimeException tabFailed) {
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
}
try {
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
} catch (RuntimeException browserFailed) {
Log.w(TAG, "No browser for the sign-in", browserFailed);
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("No browser could open the sign-in", "NO_BROWSER");
finishAuth(call);
}
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
}
@Override
protected void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
String pushUrl = intent.getStringExtra(TrekPushService.EXTRA_URL);
if (pushUrl != null) {
intent.removeExtra(TrekPushService.EXTRA_URL);
JSObject opened = new JSObject();
opened.put("url", pushUrl);
notifyListeners("pushOpened", opened);
return;
}
Uri data = intent.getData();
PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
@@ -397,7 +104,6 @@ public class TrekShellPlugin extends Plugin {
@Override
protected void handleOnResume() {
super.handleOnResume();
if (locationActive && location != null && locationAllowed()) location.start(locationHighAccuracy);
if (!authBrowserOpen) return;
authBrowserOpen = false;
handler.postDelayed(() -> {
@@ -440,370 +146,4 @@ public class TrekShellPlugin extends Plugin {
result.put("opened", false);
call.resolve(result);
}
// ---- Widgets, shortcuts and the travel day ----
/**
* What the home-screen widgets show (native/widgets.ts), as JSON: "snapshot"
* (the dashboard's), "trip.<id>" or "trips" (the list a widget and the share
* sheet pick from). The widgets redraw, and a new trip list rebuilds the
* trip shortcuts.
*/
@PluginMethod
public void setWidgetSnapshot(PluginCall call) {
String json = call.getString("json");
String key = call.getString("key", "snapshot");
if (json == null) {
call.reject("json is required");
return;
}
if (!com.liketrek.trek.widget.WidgetStore.INSTANCE.isValidKey(key)) {
call.reject("key must be snapshot, trips or trip.<id>");
return;
}
android.content.Context context = getContext().getApplicationContext();
try {
com.liketrek.trek.widget.WidgetStore.INSTANCE.write(context, key, json);
} catch (Exception e) {
call.reject("Could not store the widget snapshot", e);
return;
}
if ("trips".equals(key)) {
new Thread(() -> com.liketrek.trek.share.TrekShortcuts.INSTANCE.publish(context)).start();
} else {
com.liketrek.trek.widget.TrekWidgetUpdater.refreshAll(context);
}
call.resolve();
}
/** Starts the travel day's Live Update, or updates the one showing. */
@PluginMethod
public void syncTripDay(PluginCall call) {
String title = call.getString("tripTitle");
String path = call.getString("path");
String dayLabel = call.getString("dayLabel");
String nextName = call.getString("nextName");
if (title == null || path == null || dayLabel == null || nextName == null) {
call.reject("tripTitle, path, dayLabel and nextName are required");
return;
}
java.util.List<String> colors = new java.util.ArrayList<>();
com.getcapacitor.JSArray raw = call.getArray("colors");
if (raw != null) {
for (int i = 0; i < raw.length(); i++) colors.add(raw.isNull(i) ? null : raw.optString(i, null));
}
com.liketrek.trek.tripday.TripDayNotification.State state = new com.liketrek.trek.tripday.TripDayNotification.State(
title,
path,
dayLabel,
nextName,
call.getString("nextTime"),
call.getInt("nextMinutes"),
call.getInt("done", 0),
call.getInt("total", 0),
colors
);
boolean running = com.liketrek.trek.tripday.TripDayNotification.INSTANCE.show(getContext(), state);
JSObject result = new JSObject();
result.put("running", running);
call.resolve(result);
}
@PluginMethod
public void endTripDay(PluginCall call) {
com.liketrek.trek.tripday.TripDayNotification.INSTANCE.end(getContext());
call.resolve();
}
// ---- Location ----
private com.liketrek.trek.location.TrekLocation location;
private boolean locationHighAccuracy;
private com.liketrek.trek.location.TrekLocation location() {
if (location == null) {
location = new com.liketrek.trek.location.TrekLocation(getContext(), payload -> {
notifyListeners("location", payload);
return kotlin.Unit.INSTANCE;
});
}
return location;
}
/** Whether the app may know where the device is: precisely, or "Approximate" only. */
private boolean locationAllowed() {
return androidx.core.content.ContextCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_FINE_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED
|| androidx.core.content.ContextCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_COARSE_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED;
}
/** True while the UI has a stream running, so it resumes after the app was in the background. */
private boolean locationActive;
/**
* One location stream for the UI's navigator.geolocation, asking for the
* permission the first time. "Approximate" counts as allowed; the stream then
* runs on the coarse fixes Android gives.
*/
@PluginMethod
public void startLocation(PluginCall call) {
locationHighAccuracy = Boolean.TRUE.equals(call.getBoolean("highAccuracy", false));
if (!locationAllowed()) {
requestPermissionForAlias("location", call, "locationPermissionAnswered");
return;
}
locationActive = true;
location().start(locationHighAccuracy);
call.resolve();
}
@PermissionCallback
private void locationPermissionAnswered(PluginCall call) {
if (!locationAllowed()) {
location().emitError(1, "Location permission denied");
} else {
locationActive = true;
location().start(locationHighAccuracy);
}
call.resolve();
}
@PluginMethod
public void stopLocation(PluginCall call) {
locationActive = false;
if (location != null) location.stop();
call.resolve();
}
/** No location in the background, as in a browser: paused with the app, resumed with it. */
@Override
protected void handleOnPause() {
super.handleOnPause();
if (location != null) location.stop();
}
/** The page and the activity are gone: nothing can stop the stream any more but this. */
@Override
protected void handleOnDestroy() {
locationActive = false;
if (location != null) location.stop();
super.handleOnDestroy();
}
// ---- Map tile store ----
@PluginMethod
public void getTileCacheUsage(PluginCall call) {
new Thread(() -> {
JSObject result = new JSObject();
result.put("bytes", com.liketrek.trek.tiles.TrekTileStore.INSTANCE.usage());
result.put("limit", com.liketrek.trek.tiles.TrekTileStore.MAX_BYTES);
call.resolve(result);
}).start();
}
@PluginMethod
public void clearTileCache(PluginCall call) {
new Thread(() -> {
com.liketrek.trek.tiles.TrekTileStore.INSTANCE.clear();
call.resolve();
}).start();
}
// ---- Google Play Billing (the StoreKit methods' Android side) ----
@PluginMethod
public void storeProducts(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.products(getContext(), call);
}
@PluginMethod
public void storePurchase(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.purchase(getActivity(), call);
}
@PluginMethod
public void storeFinish(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.finish(getContext(), call);
}
@PluginMethod
public void storeRestore(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
}
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
@PluginMethod
public void storeUnfinished(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.unfinished(getContext(), call);
}
// ---- Google Wallet ----
/** The only links this hands on: Google Wallet's save-to-wallet page. */
static final String GOOGLE_WALLET_SAVE = "https://pay.google.com/gp/v/save/";
/**
* Feature googleWallet: a booking's pass, by the save link the TREK services
* signed, into Google Wallet. The Wallet app takes the link where it is
* installed, the browser otherwise.
*/
@PluginMethod
public void addToGoogleWallet(PluginCall call) {
String url = call.getString("url");
if (url == null || !url.startsWith(GOOGLE_WALLET_SAVE)) {
call.reject("not a Google Wallet save link", "INVALID");
return;
}
try {
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE));
JSObject result = new JSObject();
result.put("result", "opened");
call.resolve(result);
} catch (android.content.ActivityNotFoundException e) {
call.reject("Nothing can open Google Wallet", "UNAVAILABLE");
}
}
// ---- Large screens and keyboards ----
/** Feature keyCommands: the UI's keyboard shortcuts for the screen it shows. */
@PluginMethod
public void setKeyCommands(PluginCall call) {
com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.set(call.getArray("commands"));
call.resolve();
}
/** Feature windowLayout: the window's size classes and a foldable's posture and hinge. */
@PluginMethod
public void getWindowLayout(PluginCall call) {
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
}
// ---- Client certificates (mutual TLS) ----
/**
* The address screen's check of a server, made natively so it notices
* whether the server asks for a client certificate and can show the stored
* one (clientcert/ClientCertActions.request).
*/
@PluginMethod
public void requestServer(PluginCall call) {
String url = call.getString("url");
Uri parsed = url == null ? null : Uri.parse(url);
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
call.reject("A http(s) URL is required");
return;
}
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
new Thread(() -> {
try {
call.resolve(ClientCertActions.request(getContext(), url, timeout));
} catch (Exception e) {
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
}
}, "trek-request-server").start();
}
/** The stored certificate's details, or none. */
@PluginMethod
public void clientCertificate(PluginCall call) {
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
JSObject result = new JSObject();
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
if (asked != null) {
JSObject request = new JSObject();
request.put("host", asked.getHost());
request.put("port", asked.getPort());
request.put("served", asked.getServed());
result.put("request", request);
}
call.resolve(result);
}
@PluginMethod
public void removeClientCertificate(PluginCall call) {
TrekClientIdentity.remove(getContext());
forgetCertificateAnswers();
call.resolve();
}
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
private void forgetCertificateAnswers() {
com.liketrek.trek.clientcert.ClientCertWatch.reset();
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
}
/**
* Picks a .p12/.pfx file, asks for its password until it opens or the user
* gives up, and keeps it for the server at host and port. Resolves with
* result "imported" (and the certificate), "cancelled", or "failed" and why.
*/
@PluginMethod
public void importClientCertificate(PluginCall call) {
String host = call.getString("host");
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
call.reject("A host is required");
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
startActivityForResult(call, pick, "clientCertificatePicked");
}
@ActivityCallback
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
if (call == null) return;
Uri uri = picked.getData() == null ? null : picked.getData().getData();
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
resolveImport(call, "cancelled", null);
return;
}
new Thread(() -> {
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
else askPasswordFor(call, bytes, false);
}, "trek-client-cert").start();
}
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
JSObject labels = call.getObject("labels", new JSObject());
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
if (password == null) {
resolveImport(call, "cancelled", null);
return kotlin.Unit.INSTANCE;
}
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
return kotlin.Unit.INSTANCE;
});
}
private void openWith(PluginCall call, byte[] bytes, char[] password) {
try {
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
forgetCertificateAnswers();
resolveImport(call, "imported", null);
} catch (TrekClientIdentity.ImportException e) {
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
else resolveImport(call, "failed", e.getReason().name());
} catch (Exception e) {
Log.w(TAG, "client certificate not kept", e);
resolveImport(call, "failed", "STORE");
} finally {
java.util.Arrays.fill(password, '\0');
}
}
private void resolveImport(PluginCall call, String result, String failure) {
JSObject out = new JSObject();
out.put("result", result);
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
call.resolve(out);
}
}
@@ -1,84 +0,0 @@
package com.liketrek.trek;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Decrypts a Web Push record (RFC 8291, aes128gcm content coding of RFC 8188):
* the same thing a browser does, and the counterpart of the iOS notification
* extension's WebPushRecord. One record, as TREK's server always sends.
*/
final class TrekWebPush {
private TrekWebPush() {}
static byte[] decrypt(byte[] record, TrekPushKeys keys) throws Exception {
if (record.length < 21) throw new IllegalArgumentException("record too short");
byte[] salt = Arrays.copyOfRange(record, 0, 16);
int idLength = record[20] & 0xff;
if (idLength != 65 || record.length < 21 + idLength + 17) throw new IllegalArgumentException("unexpected header");
byte[] senderKey = Arrays.copyOfRange(record, 21, 21 + idLength);
byte[] ciphertext = Arrays.copyOfRange(record, 21 + idLength, record.length);
KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
agreement.init(keys.privateKey);
agreement.doPhase(publicKeyFrom(senderKey, keys.privateKey), true);
byte[] shared = agreement.generateSecret();
byte[] keyInfo = concat("WebPush: info\0".getBytes(StandardCharsets.US_ASCII), keys.publicKey, senderKey);
byte[] ikm = hkdf(keys.auth, shared, keyInfo, 32);
byte[] cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".getBytes(StandardCharsets.US_ASCII), 16);
byte[] nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".getBytes(StandardCharsets.US_ASCII), 12);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cek, "AES"), new GCMParameterSpec(128, nonce));
byte[] padded = cipher.doFinal(ciphertext);
// Padding: the content, one delimiter (2 for the last record), then zeros.
int end = padded.length - 1;
while (end >= 0 && padded[end] == 0) end--;
if (end < 0 || padded[end] != 2) throw new IllegalArgumentException("bad padding");
return Arrays.copyOf(padded, end);
}
private static ECPublicKey publicKeyFrom(byte[] point, ECPrivateKey onCurve) throws Exception {
if (point[0] != 0x04) throw new IllegalArgumentException("compressed key");
BigInteger x = new BigInteger(1, Arrays.copyOfRange(point, 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(point, 33, 65));
return (ECPublicKey) KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), onCurve.getParams()));
}
/** HKDF-SHA256 (RFC 5869) for outputs of at most one block. */
private static byte[] hkdf(byte[] salt, byte[] ikm, byte[] info, int length) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(info);
mac.update((byte) 1);
return Arrays.copyOf(mac.doFinal(), length);
}
private static byte[] concat(byte[]... parts) {
int length = 0;
for (byte[] part : parts) length += part.length;
byte[] out = new byte[length];
int offset = 0;
for (byte[] part : parts) {
System.arraycopy(part, 0, out, offset, part.length);
offset += part.length;
}
return out;
}
}
@@ -1,17 +1,11 @@
package com.liketrek.trek;
import android.net.Uri;
import android.webkit.ClientCertRequest;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* Used while the WebView shows the user's server. When that server cannot be
@@ -24,90 +18,10 @@ import java.util.Set;
class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable;
private final Set<String> signInHosts;
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
private boolean serverShown;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) {
super(bridge);
this.onUnreachable = onUnreachable;
this.signInHosts = new HashSet<>(signInHosts);
}
/**
* The sign-in page of a proxy in front of the server loads here rather than
* in the browser, so its session cookie is the web view's and every request
* of the app carries it, as in a browser. Only the hosts the address screen
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
* page: Capacitor injects its bridge into the server's origin alone, so the
* proxy's page gets no access to the app. Everything else goes on to
* Capacitor, which opens a foreign page in the browser.
*/
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
String learned = SignInRedirect.learnedHost(
TrekServerStore.getUrl(view.getContext()),
request.getUrl().toString(),
request.isForMainFrame(),
request.isRedirect(),
serverShown
);
if (learned != null) {
signInHosts.add(learned);
TrekServerStore.addSignInHost(view.getContext(), learned);
return false;
}
return super.shouldOverrideUrlLoading(view, request);
}
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
if (welcome != null) view.loadUrl(welcome);
}
private boolean isSignInHost(Uri url) {
String scheme = url.getScheme();
String host = url.getHost();
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
String key = host.toLowerCase(Locale.ROOT);
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
}
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
if (com.liketrek.trek.tiles.TrekTileStore.INSTANCE.handles(request)) {
WebResourceResponse stored = com.liketrek.trek.tiles.TrekTileStore.INSTANCE.serve(request);
if (stored != null) return stored;
}
return super.shouldInterceptRequest(view, request);
}
/**
* A proxy in front of the server asks for a client certificate (mutual TLS).
* The stored one goes only to the host and port it was imported for; every
* other site that asks is told there is none, as a browser without a
* matching certificate would. The WebView remembers either answer for the
* host until the app restarts, which a new certificate always brings along.
*/
@Override
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
else request.ignore();
// The page may still come from the service worker's cache while every
// request fails: check natively whether the server lets the app in.
String server = TrekServerStore.getUrl(view.getContext());
Uri serverUri = server == null ? null : Uri.parse(server);
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable);
}
}
@Override
@@ -122,22 +36,14 @@ class TrekWebViewClient extends BridgeWebViewClient {
/**
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
* and TREK serves its UI for every other path. The exception is a proxy
* that asked this page for a client certificate and got none, as Cloudflare
* does with a 403: the address screen then says so and offers to add one.
* and TREK serves its UI for every other path.
*/
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
int status = errorResponse.getStatusCode();
if (!request.isForMainFrame()) {
super.onReceivedHttpError(view, request, errorResponse);
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
} else if (errorResponse.getStatusCode() >= 500) {
onUnreachable.run();
}
}
private static boolean wantedCertificate(Uri url) {
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
}
}
@@ -1,254 +0,0 @@
package com.liketrek.trek.billing
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.PluginCall
import com.liketrek.trek.BuildConfig
/**
* TREK Cloud subscriptions through Google Play Billing, the counterpart of the
* iOS app's StoreKit 2 (TrekShellPlugin.swift, feature storeKit). The UI drives
* it through the same TrekShell methods (storeProducts, storePurchase,
* storeFinish, storeRestore) behind native/iap.ts's IapClient, so the shell's
* plan screens work unchanged; a purchase's token is the receipt the control
* plane verifies with Google (iap-google.ts in trek-hosted).
*
* Products are subscriptions with the ids `cloud.<plan>.monthly|yearly`, one base
* plan each. A purchase stays unacknowledged until storeFinish, after the
* instance exists, so a purchase whose redeem never happened is offered again
* by a restore rather than lost (Play refunds what is not acknowledged within
* three days, and the control plane acknowledges too once it has verified).
*/
object TrekBilling {
private var client: BillingClient? = null
private var pendingPurchase: PluginCall? = null
private var pendingProduct: String? = null
private val waiting = mutableListOf<(Boolean) -> Unit>()
private var connecting = false
private val listener = PurchasesUpdatedListener { result, purchases -> onPurchasesUpdated(result, purchases) }
/**
* Told of a purchase that went through outside a purchase sheet: a payment
* that was pending (cash, some carrier billing) and cleared later. The shell
* redeems it like a restore (the "storeTransaction" event), since Play refunds
* what is not acknowledged within three days.
*/
var onTransaction: ((JSObject) -> Unit)? = null
private fun client(context: Context): BillingClient = client ?: BillingClient.newBuilder(context.applicationContext)
.setListener(listener)
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().enablePrepaidPlans().build())
.enableAutoServiceReconnection()
.build()
.also { client = it }
/** Runs [then] once connected; false when Play is not there (no Play Store, a sideloaded device). */
private fun ready(context: Context, then: (Boolean) -> Unit) {
val billing = client(context)
if (billing.isReady) return then(true)
synchronized(waiting) {
waiting += then
if (connecting) return
connecting = true
}
billing.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) = flush(result.responseCode == BillingClient.BillingResponseCode.OK)
override fun onBillingServiceDisconnected() = flush(false)
})
}
private fun flush(ok: Boolean) {
val callbacks = synchronized(waiting) {
connecting = false
waiting.toList().also { waiting.clear() }
}
callbacks.forEach { it(ok) }
}
private fun details(context: Context, ids: List<String>, done: (List<ProductDetails>) -> Unit) {
if (ids.isEmpty()) return done(emptyList())
val params = QueryProductDetailsParams.newBuilder()
.setProductList(ids.map { QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(BillingClient.ProductType.SUBS).build() })
.build()
client(context).queryProductDetailsAsync(params) { result, found ->
done(if (result.responseCode == BillingClient.BillingResponseCode.OK) found.productDetailsList else emptyList())
}
}
/** The recurring price of a subscription's base plan, as Play formats it for the buyer. */
internal fun displayPrice(product: ProductDetails): String? {
val offer = baseOffer(product) ?: return null
return offer.pricingPhases.pricingPhaseList.lastOrNull()?.formattedPrice
}
/** The base plan's own offer: the one without an offer id (free trials and promotions carry one). */
private fun baseOffer(product: ProductDetails): ProductDetails.SubscriptionOfferDetails? {
val offers = product.subscriptionOfferDetails ?: return null
return offers.firstOrNull { it.offerId == null } ?: offers.firstOrNull()
}
fun products(context: Context, call: PluginCall) {
val ids = call.getArray("ids")?.toList<String>().orEmpty()
ready(context) { ok ->
val result = JSObject().put("sandbox", BuildConfig.DEBUG)
if (!ok) return@ready call.resolve(result.put("products", JSArray()))
details(context, ids) { found ->
val list = JSArray()
for (product in found) displayPrice(product)?.let { list.put(JSObject().put("id", product.productId).put("displayPrice", it)) }
call.resolve(result.put("products", list))
}
}
}
fun purchase(activity: Activity, call: PluginCall) {
val id = call.getString("productId") ?: return call.reject("productId is required")
ready(activity) { ok ->
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
details(activity, listOf(id)) { found ->
val product = found.firstOrNull()
val offer = product?.let(::baseOffer)
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
pendingPurchase = call
pendingProduct = id
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
)
.build()
activity.runOnUiThread {
val launch = client(activity).launchBillingFlow(activity, params)
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
}
}
}
}
}
private fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
if (pendingPurchase == null) {
// No sheet waiting: a pending purchase that cleared, or a renewal. Only an
// undelivered one matters; renewals are acknowledged already.
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
undelivered(purchases.orEmpty(), PREFIX)?.let { onTransaction?.invoke(receipt(it)) }
}
return
}
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> {
val purchase = purchases?.firstOrNull { pendingProduct in it.products } ?: purchases?.firstOrNull()
when (purchase?.purchaseState) {
Purchase.PurchaseState.PURCHASED -> settle("bought", purchase)
Purchase.PurchaseState.PENDING -> settle("pending", null)
else -> settle("failed", null)
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> settle("cancelled", null)
// Already subscribed on this Google account (a reinstall, another phone, a
// redeem that never happened): that subscription is the purchase, and the
// shell redeems it as it would a new one.
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> settleOwned()
else -> settle("failed", null)
}
}
private fun settleOwned() {
val call = pendingPurchase ?: return
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
client?.queryPurchasesAsync(params) { result, purchases ->
val owned = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
if (pendingPurchase !== call) return@queryPurchasesAsync
settle(if (owned != null) "bought" else "failed", owned)
} ?: settle("failed", null)
}
private fun receipt(purchase: Purchase): JSObject = JSObject()
.put("purchaseToken", purchase.purchaseToken)
.put("transactionId", purchase.purchaseToken)
.put("productId", purchase.products.firstOrNull { it.startsWith(PREFIX) } ?: purchase.products.firstOrNull())
private fun settle(kind: String, purchase: Purchase?) {
val call = pendingPurchase ?: return
pendingPurchase = null
val product = pendingProduct
pendingProduct = null
val out = JSObject().put("kind", kind)
if (purchase != null) {
out.put("purchaseToken", purchase.purchaseToken)
out.put("transactionId", purchase.purchaseToken)
out.put("productId", purchase.products.firstOrNull() ?: product)
purchase.orderId?.let { out.put("orderId", it) }
}
call.resolve(out)
}
/** Marks the purchase delivered, once its instance exists: acknowledged with Play. */
fun finish(context: Context, call: PluginCall) {
val token = call.getString("transactionId") ?: return call.reject("transactionId is required")
ready(context) { ok ->
if (!ok) return@ready call.resolve()
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build()
client(context).acknowledgePurchase(params) { call.resolve() }
}
}
/** The newest active subscription under `prefix` on this Google account, if any. */
fun restore(context: Context, call: PluginCall) {
val prefix = call.getString("prefix") ?: "cloud."
ready(context) { ok ->
if (!ok) return@ready call.resolve(JSObject())
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
client(context).queryPurchasesAsync(params) { result, purchases ->
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null
if (found == null) return@queryPurchasesAsync call.resolve(JSObject())
call.resolve(
JSObject()
.put("purchaseToken", found.purchaseToken)
.put("transactionId", found.purchaseToken)
.put("productId", found.products.first { it.startsWith(prefix) }),
)
}
}
}
/**
* A paid cloud purchase that was never delivered (not acknowledged), for the
* shell to redeem at its start: one that cleared while the app was closed.
*/
fun unfinished(context: Context, call: PluginCall) {
val prefix = call.getString("prefix") ?: PREFIX
ready(context) { ok ->
if (!ok) return@ready call.resolve(JSObject())
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
client(context).queryPurchasesAsync(params) { result, purchases ->
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) undelivered(purchases, prefix) else null
call.resolve(found?.let(::receipt) ?: JSObject())
}
}
}
internal fun undelivered(purchases: List<Purchase>, prefix: String): Purchase? =
newest(purchases.filter { !it.isAcknowledged }, prefix)
private const val PREFIX = "cloud."
internal fun newest(purchases: List<Purchase>, prefix: String): Purchase? = purchases
.filter { p -> p.purchaseState == Purchase.PurchaseState.PURCHASED && p.products.any { it.startsWith(prefix) } }
.maxByOrNull { it.purchaseTime }
}
@@ -1,151 +0,0 @@
package com.liketrek.trek.clientcert
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.net.Uri
import android.text.InputType
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import com.getcapacitor.JSObject
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
* check a server while noticing whether it asks for one, read a picked file,
* and ask for its password.
*/
object ClientCertActions {
private const val MAX_BODY_BYTES = 512 * 1024
private const val MAX_FILE_BYTES = 256 * 1024
private const val MAX_REDIRECTS = 5
/**
* A GET against the user's server for the address screen: status, body, final
* URL and headers, plus whether the server asked for a client certificate and
* whether one was shown. A failed connection answers status 0 instead of
* throwing, so a handshake the server broke off for want of a certificate
* still reports that it asked.
*/
@JvmStatic
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
var asked: Triple<String, Int, Boolean>? = null
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
val result = JSObject()
var current = URL(url)
try {
for (hop in 0..MAX_REDIRECTS) {
val connection = current.openConnection() as HttpURLConnection
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
connection.instanceFollowRedirects = false
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
try {
val status = connection.responseCode
val location = connection.getHeaderField("Location")
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
current = URL(current, location)
continue
}
val stream = if (status >= 400) connection.errorStream else connection.inputStream
result.put("status", status)
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
result.put("url", current.toString())
val headers = JSObject()
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
result.put("headers", headers)
} finally {
connection.disconnect()
}
break
}
} catch (e: IOException) {
result.put("status", 0)
result.put("data", "")
result.put("url", current.toString())
result.put("error", e.javaClass.simpleName)
}
val seen = asked
result.put("certRequested", seen != null)
result.put("certSent", seen?.third == true)
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
return result
}
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
@JvmStatic
fun readFile(context: Context, uri: Uri): ByteArray? = try {
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
} catch (e: Exception) {
null
}
/**
* The password prompt, with the UI's own words (`labels`: title, message,
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
* did not open the file. `done` gets the password, or null when cancelled.
*/
@JvmStatic
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
activity.runOnUiThread {
val input = EditText(activity).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
hint = labels.getString("placeholder") ?: ""
}
val frame = FrameLayout(activity).apply {
val pad = (20 * activity.resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
var answered = false
val dialog = AlertDialog.Builder(activity)
.setTitle(labels.getString("title"))
.setMessage(message)
.setView(frame)
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
answered = true
val text = input.text
val password = CharArray(text.length) { text[it] }
done(password)
}
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
answered = true
done(null)
}
.setOnDismissListener { if (!answered) done(null) }
.create()
dialog.show()
input.requestFocus()
}
}
/** The settings' view of the stored certificate. */
@JvmStatic
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
put("host", info.host)
put("port", info.port)
put("subject", info.subject)
put("issuer", info.issuer)
put("notAfter", info.notAfter)
}
private fun readCapped(stream: InputStream, max: Int): ByteArray {
val out = ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (out.size() < max) {
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
if (n < 0) break
out.write(buffer, 0, n)
}
return out.toByteArray()
}
}
@@ -1,50 +0,0 @@
package com.liketrek.trek.clientcert
import android.content.Context
import java.util.concurrent.atomic.AtomicLong
/**
* Notices when the user's server stops letting the app in for want of a
* client certificate: one removed, expired, or no longer trusted by the proxy.
*
* The page alone does not show it. The service worker serves TREK's start page
* from its cache, so the page loads and only its requests fail, and the UI
* reports a server it cannot reach with no way to add a certificate. So when
* the server asks for one, the app checks once natively whether TREK answers
* with what it holds. Only a check that fails at the certificate (the handshake
* broke off, or the proxy answered with its own 4xx) sends the app to the
* address screen, which says so and offers to add one. A server that merely
* asks and lets everyone in answers TREK's 200, and nothing happens.
*/
object ClientCertWatch {
private const val MIN_INTERVAL_MS = 60_000L
private const val FEATURES_PATH = "/api/health/features"
private const val TIMEOUT_MS = 8000
private val lastCheck = AtomicLong(0)
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
@JvmStatic
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
if (serverUrl == null) return
val now = System.currentTimeMillis()
val last = lastCheck.get()
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
val appContext = context.applicationContext
Thread({
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
}, "trek-client-cert-watch").start()
}
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
@JvmStatic
fun reset() {
lastCheck.set(0)
}
/** The server asked for a certificate and did not answer with TREK. */
@JvmStatic
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
}
@@ -1,261 +0,0 @@
package com.liketrek.trek.clientcert
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.io.ByteArrayInputStream
import java.io.IOException
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.UnrecoverableKeyException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509ExtendedKeyManager
/**
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
* devices that show one. The app holds at most one, for one server.
*
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
* the private key is sealed with an AES key of the Android keystore, which
* never leaves it, and stored with the certificate chain in the app's private
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
* the WebView all take, whatever its kind; a key imported into the keystore
* itself would be refused by some of them for its padding or digest rules.
*
* Only the host and port it was imported for ever see it (`identityFor`), so a
* login page, a map tile provider or liketrek.com never learns of it.
*/
object TrekClientIdentity {
private const val PREFS = "trek_client_identity"
private const val WRAP_ALIAS = "trek-client-identity-wrap"
private const val KEYMANAGER_ALIAS = "trek-client-identity"
private const val GCM_TAG_BITS = 128
/** Why a file could not be taken. */
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
class ImportException(val reason: Failure) : Exception(reason.name)
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
@Volatile private var cached: Pair<String, Identity>? = null
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
@Volatile var lastRequest: Request? = null
private set
data class Request(val host: String, val port: Int, val served: Boolean)
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
/**
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
* Throws ImportException naming what is wrong with it.
*/
@JvmStatic
@JvmOverloads
@Throws(ImportException::class)
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
val store = KeyStore.getInstance("PKCS12")
try {
store.load(ByteArrayInputStream(bytes), password)
} catch (e: IOException) {
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
} catch (e: Exception) {
throw ImportException(Failure.FORMAT)
}
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
val key = try {
store.getKey(alias, password) as? PrivateKey
} catch (e: UnrecoverableKeyException) {
throw ImportException(Failure.PASSWORD)
} ?: throw ImportException(Failure.NO_KEY)
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
val leaf = chain[0]
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
return Identity(key, chain)
}
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
private fun isPasswordFailure(e: Throwable): Boolean {
var cause: Throwable? = e
while (cause != null) {
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
cause = cause.cause
}
return false
}
/** Keeps the identity for this host and port, replacing any other. */
@JvmStatic
fun save(context: Context, identity: Identity, host: String, port: Int) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
val sealed = cipher.doFinal(identity.key.encoded)
prefs(context).edit()
.putString("host", host.lowercase(Locale.ROOT))
.putInt("port", port)
.putString("algorithm", identity.key.algorithm)
.putString("key", encode(sealed))
.putString("iv", encode(cipher.iv))
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
.apply()
cached = null
}
@JvmStatic
fun remove(context: Context) {
prefs(context).edit().clear().apply()
cached = null
}
@JvmStatic
fun info(context: Context): Info? {
val stored = stored(context) ?: return null
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
}
/** The identity for a server at this host and port, or null when there is none for it. */
@JvmStatic
fun identityFor(context: Context, host: String?, port: Int): Identity? {
val stored = stored(context) ?: return null
if (!matches(stored.host, stored.port, host, port)) return null
return opened(stored)
}
private fun opened(stored: Stored): Identity? {
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
val identity = runCatching { open(stored) }.getOrNull() ?: return null
cached = stored.sealedText to identity
return identity
}
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
@JvmStatic
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
val effective = if (port <= 0) 443 else port
return effective == storedPort
}
/** Called where a server asked for a certificate, so the address screen can offer one. */
@JvmStatic
fun noteRequest(host: String?, port: Int, served: Boolean) {
if (host == null) return
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
}
/**
* A socket factory for the app's own requests (the server check, map tiles).
* It offers the identity only when the server it is talking to asks for one
* and is the one the identity is for, and tells `onAsked` about every ask.
*/
@JvmStatic
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
val appContext = context.applicationContext
val manager = object : X509ExtendedKeyManager() {
// Decided per handshake in choose(); the key and chain are then read for
// the one alias, so parallel connections never see each other's choice.
private fun choose(host: String?, port: Int): String? {
val identity = identityFor(appContext, host, port)
if (host != null) {
noteRequest(host, port, identity != null)
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
}
return if (identity != null) KEYMANAGER_ALIAS else null
}
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
val session = (socket as? SSLSocket)?.handshakeSession
return choose(session?.peerHost, session?.peerPort ?: -1)
}
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
choose(engine?.peerHost, engine?.peerPort ?: -1)
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
override fun getPrivateKey(alias: String?): PrivateKey? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
}
val tls = SSLContext.getInstance("TLS")
tls.init(arrayOf(manager), null, null)
return tls.socketFactory
}
private fun open(stored: Stored): Identity {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
}
private fun stored(context: Context): Stored? {
val p = prefs(context)
val host = p.getString("host", null) ?: return null
val algorithm = p.getString("algorithm", null) ?: return null
val key = p.getString("key", null) ?: return null
val iv = p.getString("iv", null) ?: return null
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
if (chain.isEmpty()) return null
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
}
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
private fun wrapKey(): SecretKey {
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return generator.generateKey()
}
private fun certificate(der: ByteArray): X509Certificate =
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
/** The CN of a name, or the whole name when it has none. */
@JvmStatic
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
}
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
}
@@ -1,91 +0,0 @@
package com.liketrek.trek.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.Build
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import androidx.core.content.ContextCompat
import com.getcapacitor.JSObject
import org.json.JSONObject
/**
* One location stream for the UI's navigator.geolocation (native/geolocation.ts),
* the counterpart of TrekLocation in TrekShellPlugin.swift: the system's fused
* provider where there is one (Android 12+), GPS and network before, with
* positions and errors shaped like the Geolocation API's. Asking for the
* permission is the plugin's part; this starts once it is granted.
*/
class TrekLocation(context: Context, private val emit: (JSObject) -> Unit) {
private val manager = context.getSystemService(LocationManager::class.java)
private val executor = ContextCompat.getMainExecutor(context)
private val listeners = mutableListOf<LocationListenerCompat>()
@SuppressLint("MissingPermission")
fun start(highAccuracy: Boolean) {
stop()
if (manager == null || !LocationManagerCompat.isLocationEnabled(manager)) {
emitError(2, "Location services are off")
return
}
val request = LocationRequestCompat.Builder(if (highAccuracy) 2_000L else 10_000L)
.setQuality(if (highAccuracy) LocationRequestCompat.QUALITY_HIGH_ACCURACY else LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY)
.setMinUpdateDistanceMeters(if (highAccuracy) 2f else 25f)
.build()
val providers = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && manager.hasProvider(LocationManager.FUSED_PROVIDER) -> listOf(LocationManager.FUSED_PROVIDER)
else -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER).filter { manager.isProviderEnabled(it) }
}
if (providers.isEmpty()) {
emitError(2, "No location provider")
return
}
// The last known fix answers getCurrentPosition at once; a fresh one follows.
providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
?.takeIf { System.currentTimeMillis() - it.time < 120_000 }
?.let { emit(payload(it)) }
for (provider in providers) {
val listener = LocationListenerCompat { location -> emit(payload(location)) }
try {
LocationManagerCompat.requestLocationUpdates(manager, provider, request, executor, listener)
listeners += listener
} catch (e: SecurityException) {
emitError(1, "Location permission denied")
return
}
}
}
// Removing updates needs no permission of its own; one revoked meanwhile only means there is nothing left to stop.
@SuppressLint("MissingPermission")
fun stop() {
val m = manager ?: return
for (listener in listeners) {
try {
LocationManagerCompat.removeUpdates(m, listener)
} catch (e: SecurityException) {
// Nothing to stop.
}
}
listeners.clear()
}
fun emitError(code: Int, message: String) {
emit(JSObject().put("error", JSObject().put("code", code).put("message", message)))
}
companion object {
fun payload(location: Location): JSObject = JSObject()
.put("latitude", location.latitude)
.put("longitude", location.longitude)
.put("accuracy", location.accuracy.toDouble())
.put("altitude", if (location.hasAltitude()) location.altitude else JSONObject.NULL)
.put("speed", if (location.hasSpeed()) location.speed.toDouble() else JSONObject.NULL)
.put("heading", if (location.hasBearing()) location.bearing.toDouble() else JSONObject.NULL)
.put("timestamp", location.time.toDouble())
}
}
@@ -1,101 +0,0 @@
package com.liketrek.trek.screen
import android.view.KeyEvent
import android.view.KeyboardShortcutGroup
import android.view.KeyboardShortcutInfo
import org.json.JSONArray
import org.json.JSONObject
/**
* The UI's keyboard shortcuts as the app's own (TrekShell.setKeyCommands, feature
* keyCommands), the Android side of TrekKeyCommands in the iOS shell: a hardware
* keyboard's shortcut runs the UI's command even while the focus sits outside the
* page, and the system's shortcut list (Meta + /) shows them under their names in
* the UI's language. Each screen replaces the list, so only what works here is on it.
*
* "command" is Ctrl on Android (Meta is accepted too, for keyboards made for a Mac),
* "option" is Alt.
*/
object TrekKeyCommands {
data class Command(val id: String, val title: String, val keyCode: Int, val modifiers: Int)
@Volatile var current: List<Command> = emptyList()
private set
var onPress: ((String) -> Unit)? = null
fun set(raw: JSONArray?) {
current = parse(raw)
}
fun parse(raw: JSONArray?): List<Command> {
if (raw == null) return emptyList()
return (0 until raw.length()).mapNotNull { i ->
val o: JSONObject = raw.optJSONObject(i) ?: return@mapNotNull null
val id = o.optString("id").ifEmpty { return@mapNotNull null }
val title = o.optString("title").ifEmpty { return@mapNotNull null }
val key = keyCode(o.optString("input")) ?: return@mapNotNull null
var meta = 0
val mods = o.optJSONArray("modifiers")
for (j in 0 until (mods?.length() ?: 0)) {
meta = meta or when (mods!!.optString(j)) {
"command", "control" -> KeyEvent.META_CTRL_ON
"shift" -> KeyEvent.META_SHIFT_ON
"option" -> KeyEvent.META_ALT_ON
else -> 0
}
}
Command(id, title, key, meta)
}
}
/** The key a UI input names: a letter, a digit, an arrow, escape or a punctuation mark. */
fun keyCode(input: String): Int? = when (input.lowercase()) {
"left" -> KeyEvent.KEYCODE_DPAD_LEFT
"right" -> KeyEvent.KEYCODE_DPAD_RIGHT
"up" -> KeyEvent.KEYCODE_DPAD_UP
"down" -> KeyEvent.KEYCODE_DPAD_DOWN
"escape" -> KeyEvent.KEYCODE_ESCAPE
"enter", "\r" -> KeyEvent.KEYCODE_ENTER
"," -> KeyEvent.KEYCODE_COMMA
"." -> KeyEvent.KEYCODE_PERIOD
"/" -> KeyEvent.KEYCODE_SLASH
"[" -> KeyEvent.KEYCODE_LEFT_BRACKET
"]" -> KeyEvent.KEYCODE_RIGHT_BRACKET
"-" -> KeyEvent.KEYCODE_MINUS
"=" -> KeyEvent.KEYCODE_EQUALS
" ", "space" -> KeyEvent.KEYCODE_SPACE
else -> when {
input.length == 1 && input[0].lowercaseChar() in 'a'..'z' -> KeyEvent.KEYCODE_A + (input[0].lowercaseChar() - 'a')
input.length == 1 && input[0] in '0'..'9' -> KeyEvent.KEYCODE_0 + (input[0] - '0')
else -> null
}
}
private const val MASK = KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON or KeyEvent.META_ALT_ON
/** The command a key press stands for, if any. Meta counts as Ctrl. */
fun match(keyCode: Int, metaState: Int): Command? {
var state = metaState
if (state and KeyEvent.META_META_ON != 0) state = state or KeyEvent.META_CTRL_ON
val pressed = state and MASK
return current.firstOrNull { it.keyCode == keyCode && it.modifiers == pressed }
}
/** Runs the matching command for a key going down. True when one took it. */
fun dispatch(event: KeyEvent): Boolean {
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return false
val command = match(event.keyCode, event.metaState) ?: return false
// A plain key (no modifier) is left to a text field that has the focus.
if (command.modifiers == 0 && event.keyCode != KeyEvent.KEYCODE_ESCAPE) return false
val press = onPress ?: return false
press(command.id)
return true
}
/** The list the system shows for Meta + /. */
fun shortcutGroup(label: CharSequence): KeyboardShortcutGroup? {
val items = current.map { KeyboardShortcutInfo(it.title, it.keyCode, it.modifiers) }
return if (items.isEmpty()) null else KeyboardShortcutGroup(label, items)
}
}
@@ -1,82 +0,0 @@
package com.liketrek.trek.screen
import android.app.Activity
import android.graphics.Rect
import androidx.activity.ComponentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
import androidx.window.layout.WindowMetricsCalculator
import com.getcapacitor.JSObject
import kotlinx.coroutines.launch
/**
* The window's shape for the UI (TrekShell.getWindowLayout and the
* "windowLayout" event, feature windowLayout): its size class by Android's
* breakpoints, and a foldable's posture and hinge through Jetpack WindowManager.
*
* The layouts themselves follow the CSS size classes (hooks/sizeClass.ts), which
* the WebView answers from the window like any browser; this adds what CSS cannot
* see: that a Galaxy Z Fold or Pixel Fold is half open like a laptop or a book,
* and where the hinge runs, so a layout can keep content off the fold. Lengths
* are in CSS pixels, relative to the window.
*/
object TrekWindowLayout {
/** The last WindowLayoutInfo WindowManager reported, with the fold in it. */
@Volatile private var lastInfo: WindowLayoutInfo? = null
/** The window now, with the posture last reported: what getWindowLayout answers. */
fun current(activity: Activity): JSObject = describe(activity, lastInfo)
fun observe(activity: ComponentActivity, emit: (JSObject) -> Unit) {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info ->
lastInfo = info
emit(describe(activity, info))
}
}
}
}
fun describe(activity: Activity, info: WindowLayoutInfo?): JSObject {
val density = activity.resources.displayMetrics.density
val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds
val widthDp = bounds.width() / density
val heightDp = bounds.height() / density
val result = JSObject()
.put("width", widthDp.toInt())
.put("height", heightDp.toInt())
.put("widthClass", sizeClass(widthDp, 600f, 840f))
.put("heightClass", sizeClass(heightDp, 480f, 900f))
val fold = info?.displayFeatures?.filterIsInstance<FoldingFeature>()?.firstOrNull()
if (fold == null) {
result.put("posture", "none")
} else {
val r: Rect = fold.bounds
result.put("posture", if (fold.state == FoldingFeature.State.HALF_OPENED) "halfOpened" else "flat")
.put("orientation", if (fold.orientation == FoldingFeature.Orientation.VERTICAL) "vertical" else "horizontal")
.put("separating", fold.isSeparating)
.put("occluding", fold.occlusionType == FoldingFeature.OcclusionType.FULL)
.put(
"hinge",
JSObject()
.put("left", ((r.left - bounds.left) / density).toInt())
.put("top", ((r.top - bounds.top) / density).toInt())
.put("right", ((r.right - bounds.left) / density).toInt())
.put("bottom", ((r.bottom - bounds.top) / density).toInt()),
)
}
return result
}
/** Android's window size classes: compact, medium, expanded. */
fun sizeClass(dp: Float, medium: Float, expanded: Float): String = when {
dp < medium -> "compact"
dp < expanded -> "medium"
else -> "expanded"
}
}
@@ -1,431 +0,0 @@
package com.liketrek.trek.share
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.text.format.Formatter
import android.view.HapticFeedbackConstants
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.Email
import androidx.compose.material.icons.rounded.Image
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PictureAsPdf
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.ShortText
import androidx.compose.material.icons.rounded.Wallet
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.lifecycle.lifecycleScope
import com.liketrek.trek.MainActivity
import com.liketrek.trek.R
import com.liketrek.trek.ui.TrekMaterialTheme
import com.liketrek.trek.widget.WidgetArt
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* "TREK" in the system share sheet (and "Open with TREK"): a Material bottom
* sheet over the app that shared, showing what came in, the trip it goes to and
* one button, the counterpart of the iOS share extension (ShareView.swift). The
* item and the trip are left for the app (ShareHandoff) and TREK opens, where
* the booking import takes over. A direct-share shortcut of a trip
* (TrekShortcuts) arrives with that trip already picked.
*/
class ShareActivity : ComponentActivity() {
private sealed interface Phase {
data object Loading : Phase
data object Ready : Phase
data object Saving : Phase
data object Done : Phase
data class Failed(val message: Int) : Phase
}
private var phase by mutableStateOf<Phase>(Phase.Loading)
private var item by mutableStateOf<ShareItem?>(null)
private var trips by mutableStateOf<List<ShareHandoff.Trip>>(emptyList())
private var selected by mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
trips = ShareHandoff.trips(this)
val shortcutTrip = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)?.removePrefix("trip.")?.toLongOrNull()
selected = if (savedInstanceState?.containsKey(SELECTED) == true) {
// The trip picked before the system recreated the sheet; -1 is "choose later".
savedInstanceState.getLong(SELECTED).takeIf { it >= 0 }
} else {
shortcutTrip?.takeIf { id -> trips.any { it.id == id } } ?: trips.firstOrNull()?.id
}
val incoming = intent
lifecycleScope.launch {
phase = try {
item = withContext(Dispatchers.IO) { ShareLoader.load(incoming, contentResolver) }
Phase.Ready
} catch (e: ShareLoadException) {
Phase.Failed(if (e.reason == ShareLoadException.Reason.TOO_LARGE) R.string.share_too_large else R.string.share_unsupported)
} catch (e: Exception) {
Phase.Failed(R.string.share_unsupported)
}
}
setContent {
TrekMaterialTheme {
Sheet()
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong(SELECTED, selected ?: -1L)
}
private fun add() {
val current = item ?: return
if (phase != Phase.Ready) return
phase = Phase.Saving
lifecycleScope.launch {
val stored = withContext(Dispatchers.IO) { ShareHandoff.store(this@ShareActivity, current, selected) }
if (!stored) {
window.decorView.performHapticFeedback(HapticFeedbackConstants.REJECT)
phase = Phase.Failed(R.string.share_failed)
return@launch
}
window.decorView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
phase = Phase.Done
delay(850)
startActivity(
Intent(this@ShareActivity, MainActivity::class.java)
.setAction(MainActivity.ACTION_SHARE_HANDOFF)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
finish()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Sheet() {
val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val dismiss: () -> Unit = {
scope.launch { state.hide() }.invokeOnCompletion { finish() }
}
ModalBottomSheet(
onDismissRequest = { finish() },
sheetState = state,
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) {
Header(onClose = dismiss)
Spacer(Modifier.height(16.dp))
when (val now = phase) {
is Phase.Failed -> Failure(stringResource(now.message), onClose = dismiss)
else -> Content(now)
}
}
}
}
@Composable
private fun Header(onClose: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text("T", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Black, fontSize = 21.sp)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text("TREK", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, letterSpacing = 2.4.sp)
Text(stringResource(R.string.share_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
TextButton(onClick = onClose) { Text(stringResource(R.string.share_cancel)) }
}
}
@Composable
private fun Content(now: Phase) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
ItemCard(item, Modifier.padding(horizontal = 20.dp))
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.share_trip).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
modifier = Modifier.padding(horizontal = 22.dp),
)
if (trips.isEmpty()) {
Text(
stringResource(R.string.share_no_trips),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 22.dp),
)
} else {
TripRail()
}
}
Text(
stringResource(R.string.share_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 22.dp),
)
AddButton(now)
}
}
@Composable
private fun ItemCard(item: ShareItem?, modifier: Modifier) {
val context = LocalContext.current
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
if (item == null) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(kindIcon(item.kind), null, tint = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
item?.title ?: stringResource(R.string.share_reading),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (item != null) {
val parts = listOfNotNull(stringResource(kindLabel(item.kind)), Formatter.formatShortFileSize(context, item.data.size.toLong()), item.source)
Text(parts.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
}
}
}
@Composable
private fun TripRail() {
LazyRow(contentPadding = PaddingValues(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(trips, key = { it.id }) { trip ->
TripCard(trip, selected == trip.id) { selected = trip.id }
}
item {
FilterChip(
selected = selected == null,
onClick = { selected = null },
label = { Text(stringResource(R.string.share_later)) },
leadingIcon = { Icon(Icons.Rounded.Schedule, null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.height(132.dp).padding(vertical = 44.dp),
)
}
}
}
@Composable
private fun TripCard(trip: ShareHandoff.Trip, isSelected: Boolean, onClick: () -> Unit) {
val view = LocalView.current
val cover: Bitmap? = remember(trip.id) { WidgetArt.cover(trip.cover, 360) ?: WidgetArt.sky(trip.title, 300, 200) }
val border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
Surface(
shape = RoundedCornerShape(20.dp),
border = border,
modifier = Modifier.width(168.dp).height(132.dp).clip(RoundedCornerShape(20.dp)).clickable {
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
onClick()
},
) {
Box {
if (cover != null) {
Image(cover.asImageBitmap(), null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
}
Box(
modifier = Modifier.fillMaxSize().background(
Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.1f), Color.Black.copy(alpha = 0.68f))),
),
)
if (isSelected) {
Box(
modifier = Modifier.padding(8.dp).size(24.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary).align(Alignment.TopEnd),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Rounded.Check, null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(16.dp))
}
}
Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) {
Text(when_(trip), color = Color.White.copy(alpha = 0.82f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
Text(trip.title, color = Color.White, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
private fun when_(trip: ShareHandoff.Trip): String {
val today = LocalDate.now()
val start = trip.start
val end = trip.end
return when {
end != null && end < today -> stringResource(R.string.share_past)
start == null || start <= today -> stringResource(R.string.share_now)
ChronoUnit.DAYS.between(today, start) == 1L -> stringResource(R.string.share_tomorrow)
else -> stringResource(R.string.share_in_days, ChronoUnit.DAYS.between(today, start).toInt())
}
}
@Composable
private fun AddButton(now: Phase) {
val title = trips.firstOrNull { it.id == selected }?.title
Button(
onClick = ::add,
enabled = now == Phase.Ready,
shape = RoundedCornerShape(18.dp),
colors = if (now == Phase.Done) {
ButtonDefaults.buttonColors(containerColor = Color(0xFF2FA37A), disabledContainerColor = Color(0xFF2FA37A), disabledContentColor = Color.White)
} else {
ButtonDefaults.buttonColors()
},
contentPadding = PaddingValues(vertical = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
) {
AnimatedContent(
targetState = now,
transitionSpec = { (fadeIn() + scaleIn(spring(dampingRatio = 0.6f))) togetherWith fadeOut() },
label = "add",
) { target ->
when (target) {
Phase.Saving, Phase.Loading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
Phase.Done -> Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Check, null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.share_added), fontWeight = FontWeight.Bold)
}
else -> Text(
if (title != null) stringResource(R.string.share_add_to, title) else stringResource(R.string.share_add),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun Failure(message: String, onClose: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Rounded.ErrorOutline, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(40.dp))
Spacer(Modifier.height(12.dp))
Text(message, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(16.dp))
Button(onClick = onClose, shape = RoundedCornerShape(18.dp)) { Text(stringResource(R.string.share_close)) }
}
}
private fun kindIcon(kind: ShareItem.Kind): ImageVector = when (kind) {
ShareItem.Kind.PASS -> Icons.Rounded.Wallet
ShareItem.Kind.PDF -> Icons.Rounded.PictureAsPdf
ShareItem.Kind.MAIL -> Icons.Rounded.Email
ShareItem.Kind.CALENDAR -> Icons.Rounded.CalendarMonth
ShareItem.Kind.PAGE -> Icons.Rounded.Language
ShareItem.Kind.TEXT -> Icons.Rounded.ShortText
ShareItem.Kind.IMAGE -> Icons.Rounded.Image
ShareItem.Kind.FILE -> Icons.Rounded.Description
}
private fun kindLabel(kind: ShareItem.Kind): Int = when (kind) {
ShareItem.Kind.PASS -> R.string.share_kind_pass
ShareItem.Kind.PDF -> R.string.share_kind_pdf
ShareItem.Kind.MAIL -> R.string.share_kind_mail
ShareItem.Kind.CALENDAR -> R.string.share_kind_file
ShareItem.Kind.PAGE -> R.string.share_kind_page
ShareItem.Kind.TEXT -> R.string.share_kind_text
ShareItem.Kind.IMAGE -> R.string.share_kind_image
ShareItem.Kind.FILE -> R.string.share_kind_file
}
private companion object {
const val SELECTED = "trek.share.selected"
}
}
@@ -1,69 +0,0 @@
package com.liketrek.trek.share
import android.content.Context
import com.liketrek.trek.widget.WidgetSnapshot
import com.liketrek.trek.widget.WidgetStore
import java.io.File
import java.time.LocalDate
import org.json.JSONObject
/**
* The trips the share sheet offers, and the hand-over of the shared item to the
* app. The trip list is the one the app keeps for the widgets (WidgetStore, key
* "trips"); the item waits in the app's cache until MainActivity hands it to the
* UI (TrekSharedFiles.receiveHandoff), the way ShareHandoff.swift does through
* the keychain on iOS.
*/
object ShareHandoff {
data class Trip(
val id: Long,
val title: String,
val start: LocalDate?,
val end: LocalDate?,
val place: String?,
/** The small cover from the trip's widget snapshot, base64, when the app wrote one. */
val cover: String?,
)
/** The trips that have not ended yet, soonest first, then the six most recent past ones. */
fun trips(context: Context, today: LocalDate = LocalDate.now()): List<Trip> {
val all = WidgetStore.trips(context).map { ref ->
val snapshot = WidgetStore.read(context, "trip.${ref.id}")?.let(WidgetSnapshot::parse)
Trip(ref.id, ref.title, WidgetSnapshot.day(ref.start), WidgetSnapshot.day(ref.end), snapshot?.trip?.place, snapshot?.trip?.cover)
}
return order(all, today)
}
internal fun order(all: List<Trip>, today: LocalDate): List<Trip> {
val ahead = all.filter { (it.end ?: LocalDate.MAX) >= today }.sortedBy { it.start ?: LocalDate.MAX }
val past = all.filter { (it.end ?: LocalDate.MAX) < today }.sortedByDescending { it.end ?: LocalDate.MIN }
return ahead + past.take(6)
}
private fun dir(context: Context) = File(context.cacheDir, "share").apply { mkdirs() }
/** Leaves the item for the app, with the trip it goes to (null: the app asks). */
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
if (tripId != null) meta.put("tripId", tripId)
if (item.url != null) meta.put("url", item.url).put("title", item.title)
File(dir(context), "pending.data").writeBytes(item.data)
File(dir(context), "pending.json").writeText(meta.toString())
true
}.getOrDefault(false)
/** The item left by [store], taken once. */
fun take(context: Context): Pair<JSONObject, ByteArray>? {
val metaFile = File(dir(context), "pending.json")
val dataFile = File(dir(context), "pending.data")
if (!metaFile.exists() || !dataFile.exists()) return null
return try {
JSONObject(metaFile.readText()) to dataFile.readBytes()
} catch (e: Exception) {
null
} finally {
metaFile.delete()
dataFile.delete()
}
}
}
@@ -1,210 +0,0 @@
package com.liketrek.trek.share
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* What was shared to TREK, read into a file the booking import takes: a wallet
* pass, a PDF, a mail, a calendar entry, a web page, a piece of text or a photo.
* The import in the app (BookingImportModal) decides what it can read; this only
* carries the bytes over with a name and a type, like ShareItem.swift on iOS.
*/
data class ShareItem(
val kind: Kind,
/** The file name the import sees. */
val name: String,
val mimeType: String,
val data: ByteArray,
/** What the sheet shows: a page title, a file name, the first line of a text. */
val title: String,
/** A host, for a page. */
val source: String? = null,
/**
* The web address it came from, when it is a page or a link: the app offers to
* keep it among the trip's links as well as to read a booking out of it.
*/
val url: String? = null,
) {
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
override fun equals(other: Any?): Boolean = other is ShareItem && other.name == name && other.data.contentEquals(data)
override fun hashCode(): Int = name.hashCode() * 31 + data.contentHashCode()
}
class ShareLoadException(val reason: Reason) : Exception(reason.name) {
enum class Reason { UNSUPPORTED, TOO_LARGE }
}
object ShareLoader {
/** The same ceiling as the app's import of an opened file (TrekSharedFiles). */
const val MAX_BYTES = 15 * 1024 * 1024
private const val PAGE_TIMEOUT_MS = 10_000
/** Reads what [intent] carries. Runs on a background thread: it may read a file or fetch a page. */
fun load(intent: Intent, resolver: ContentResolver): ShareItem {
val stream = streamOf(intent)
if (stream != null) return fromUri(stream, intent.type, resolver)
val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim()
if (!text.isNullOrEmpty()) return fromText(text, intent.getStringExtra(Intent.EXTRA_SUBJECT))
throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
}
/**
* The shared document, content:// only. The share target is open to every app,
* and a file:// path would be read with TREK's rights, its own private files
* included; every sender today shares through a content provider anyway.
*/
private fun streamOf(intent: Intent): Uri? = when (intent.action) {
Intent.ACTION_VIEW -> intent.data
Intent.ACTION_SEND -> parcelable(intent, Intent.EXTRA_STREAM)
Intent.ACTION_SEND_MULTIPLE -> parcelableList(intent)?.firstOrNull()
else -> null
}?.takeIf(::isShareable)
internal fun isShareable(uri: Uri): Boolean = uri.scheme == ContentResolver.SCHEME_CONTENT
@Suppress("DEPRECATION")
private fun parcelable(intent: Intent, key: String): Uri? =
if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(key, Uri::class.java) else intent.getParcelableExtra(key)
@Suppress("DEPRECATION")
private fun parcelableList(intent: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
private fun fromUri(uri: Uri, intentType: String?, resolver: ContentResolver): ShareItem {
val name = displayName(uri, resolver)
val size = runCatching {
resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else null }
}.getOrNull()
if (size != null && size > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE)
val data = resolver.openInputStream(uri)?.use { readCapped(it) } ?: throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
val mime = resolver.getType(uri) ?: intentType ?: "application/octet-stream"
val (kind, normalized) = classify(name, mime)
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
}
/**
* A link becomes the page it points at, or the bare link when the page cannot be
* read (a sign-in wall, an app-only page, no network), which the app can still
* keep among the trip's links. Anything else, a text file the import reads.
*/
internal fun fromText(text: String, subject: String?): ShareItem {
val link = firstLink(text)
if (link != null) {
runCatching { return page(link, subject) }
return linkOnly(link, subject)
}
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
}
/** The first http(s) link in a text, when the text is not much more than that link. */
internal fun firstLink(text: String): String? {
val match = Regex("https?://[^\\s<>\"]+").find(text) ?: return null
// A long mail body with a link in it is the booking itself, not a pointer to one.
return if (text.length - match.value.length <= 200) match.value.trimEnd('.', ',', ')', ']') else null
}
private fun page(link: String, subject: String?): ShareItem {
val connection = URL(link).openConnection() as HttpURLConnection
connection.connectTimeout = PAGE_TIMEOUT_MS
connection.readTimeout = PAGE_TIMEOUT_MS
connection.instanceFollowRedirects = true
connection.setRequestProperty("Accept", "text/html")
try {
val code = connection.responseCode
val type = connection.contentType.orEmpty()
if (code !in 200..299 || !type.contains("html")) throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
val data = connection.inputStream.use { readCapped(it) }
val html = String(data, Charsets.UTF_8)
val host = runCatching { Uri.parse(link).host }.getOrNull()
val title = pageTitle(html) ?: subject ?: host ?: "Page"
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
} finally {
connection.disconnect()
}
}
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
internal fun linkOnly(link: String, subject: String?): ShareItem {
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
val host = runCatching { java.net.URI(link).host }.getOrNull()
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
}
/** The kind and the MIME type the import should see, from the name when the type is vague. */
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
val ext = name.substringAfterLast('.', "").lowercase()
return when {
ext == "pkpass" || mime == "application/vnd.apple.pkpass" -> ShareItem.Kind.PASS to "application/vnd.apple.pkpass"
ext == "pdf" || mime == "application/pdf" -> ShareItem.Kind.PDF to "application/pdf"
ext == "eml" || mime == "message/rfc822" -> ShareItem.Kind.MAIL to "message/rfc822"
ext == "ics" || mime == "text/calendar" -> ShareItem.Kind.CALENDAR to "text/calendar"
ext == "html" || ext == "htm" || mime == "text/html" -> ShareItem.Kind.PAGE to "text/html"
mime.startsWith("image/") -> ShareItem.Kind.IMAGE to mime
ext == "txt" || mime == "text/plain" -> ShareItem.Kind.TEXT to "text/plain"
else -> ShareItem.Kind.FILE to mime
}
}
private fun withExtension(name: String, kind: ShareItem.Kind): String {
val ext = when (kind) {
ShareItem.Kind.PASS -> "pkpass"
ShareItem.Kind.PDF -> "pdf"
ShareItem.Kind.MAIL -> "eml"
ShareItem.Kind.CALENDAR -> "ics"
ShareItem.Kind.PAGE -> "html"
else -> return name
}
return if (name.lowercase().endsWith(".$ext")) name else "$name.$ext"
}
private fun displayName(uri: Uri, resolver: ContentResolver): String {
runCatching {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst() && !it.isNull(0)) return it.getString(0)
}
}
return uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } ?: "booking"
}
private fun readCapped(input: InputStream): ByteArray {
val out = ByteArrayOutputStream()
val buffer = ByteArray(16 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
if (out.size() > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE)
}
return out.toByteArray()
}
private fun checked(item: ShareItem): ShareItem {
if (item.data.isEmpty()) throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED)
if (item.data.size > MAX_BYTES) throw ShareLoadException(ShareLoadException.Reason.TOO_LARGE)
return item
}
internal fun pageTitle(html: String): String? {
val match = Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)).find(html) ?: return null
return match.groupValues[1].trim().take(120).ifBlank { null }
}
internal fun slug(title: String): String {
val words = title.lowercase().split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() }
return words.take(8).joinToString("-").ifBlank { "page" }
}
}
@@ -1,66 +0,0 @@
package com.liketrek.trek.share
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.net.Uri
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.liketrek.trek.MainActivity
import com.liketrek.trek.widget.WidgetArt
import com.liketrek.trek.widget.WidgetSnapshot
import com.liketrek.trek.widget.WidgetStore
/**
* The trips as shortcuts: a long press on TREK's icon lists the coming ones and
* opens a trip straight away, and the system share sheet offers them as direct
* share targets, so a booking goes into its trip in one tap (ShareActivity
* reads the shortcut's id). Rebuilt whenever the app writes its trip list.
*/
object TrekShortcuts {
/** The category res/xml/shortcuts.xml's share-target names. */
const val SHARE_CATEGORY = "com.liketrek.trek.category.TRIP_SHARE"
private const val MAX = 4
fun publish(context: Context) {
val trips = ShareHandoff.trips(context).take(MAX)
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(1)
val shortcuts = trips.take(limit).mapIndexed { rank, trip ->
val open = Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link("/trips/${trip.id}")), context, MainActivity::class.java)
ShortcutInfoCompat.Builder(context, "trip.${trip.id}")
.setShortLabel(trip.title.take(24))
.setLongLabel(trip.title)
.setIcon(IconCompat.createWithAdaptiveBitmap(icon(trip)))
.setIntent(open)
.setCategories(setOf(SHARE_CATEGORY))
.setLongLived(true)
.setRank(rank)
.build()
}
runCatching {
// Trips no longer listed lose their shortcut; pinned ones stay but are disabled by the system.
val keep = shortcuts.map { it.id }.toSet()
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in keep }
if (stale.isNotEmpty()) ShortcutManagerCompat.removeLongLivedShortcuts(context, stale)
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
}
}
/** The trip's cover, else its own sky, cropped for an adaptive icon (108dp with a 72dp safe zone). */
private fun icon(trip: ShareHandoff.Trip): Bitmap {
val size = 216
val source = WidgetArt.cover(trip.cover, 432) ?: WidgetArt.sky(trip.title, size, size)
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val side = minOf(source.width, source.height)
val from = Rect((source.width - side) / 2, (source.height - side) / 2, (source.width + side) / 2, (source.height + side) / 2)
Canvas(out).drawBitmap(source, from, Rect(0, 0, size, size), Paint(Paint.FILTER_BITMAP_FLAG))
return out
}
/** Whether there is anything to publish: the app wrote a trip list. */
fun hasTrips(context: Context): Boolean = WidgetStore.trips(context).isNotEmpty()
}
@@ -1,164 +0,0 @@
package com.liketrek.trek.tiles
import android.content.Context
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import java.io.ByteArrayInputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* The app's own store of Mapbox map resources, the counterpart of the iOS
* TrekTileStore (TrekBridgeViewController.swift).
*
* Android's WebView runs the server's service worker, which keeps every raster
* tile the user looked at. Mapbox's vector tiles, styles, sprites and glyphs it
* may not keep, but a device cache of what was viewed is allowed for 30 days,
* so the WebView client hands those requests here (TrekWebViewClient), and a
* Mapbox map keeps working offline for the places already seen. Nothing is
* prefetched; the store is pruned to [MAX_BYTES], least recently used first.
*/
object TrekTileStore {
const val MAX_AGE_MS = 30L * 24 * 60 * 60 * 1000
const val MAX_BYTES = 250L * 1024 * 1024
private const val PRUNE_TARGET = 0.8
private const val TIMEOUT_MS = 15_000
/** One resource is a tile, a style or a sprite sheet: far below this. */
private const val MAX_RESOURCE_BYTES = 8 * 1024 * 1024
private val HOST = Regex("^(api\\.mapbox\\.com|[a-z0-9-]+\\.tiles\\.mapbox\\.com)$", RegexOption.IGNORE_CASE)
/** What a map draws; telemetry, sessions and anything else go straight out. */
private val PATHS = listOf("/v4/", "/styles/", "/fonts/", "/models/", "/rasterarrays/", "/raster/")
private val DROPPED = setOf("access_token", "sku", "key", "api_key", "apikey")
@Volatile private var dir: File? = null
private var writesSincePrune = 0
fun init(context: Context) {
if (dir == null) dir = File(context.cacheDir, "tiles").apply { mkdirs() }
}
fun handles(request: WebResourceRequest): Boolean = request.method == "GET" && handles(request.url)
fun handles(url: Uri): Boolean = url.scheme == "https" && HOST.matches(url.host.orEmpty()) && PATHS.any { url.path.orEmpty().startsWith(it) }
/** The key of a resource: its URL without the credentials and the billing session, which change. */
fun key(url: Uri): String {
val builder = url.buildUpon().clearQuery()
url.queryParameterNames.filter { it.lowercase() !in DROPPED }.sorted().forEach { name ->
url.getQueryParameters(name).forEach { builder.appendQueryParameter(name, it) }
}
val digest = MessageDigest.getInstance("SHA-256").digest(builder.build().toString().toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
/**
* Answers a Mapbox request from the store when it is younger than 30 days,
* else from the network, keeping what came back. Null lets the WebView load
* it itself (no store, or the network failed).
*/
fun serve(request: WebResourceRequest): WebResourceResponse? {
val root = dir ?: return null
val url = request.url
val key = key(url)
val body = File(root, "$key.bin")
val meta = File(root, "$key.type")
val now = System.currentTimeMillis()
if (body.exists() && now - created(meta) < MAX_AGE_MS) {
body.setLastModified(now)
val type = runCatching { meta.readLines().getOrNull(1) }.getOrNull() ?: "application/octet-stream"
return runCatching { response(body.readBytes(), type) }.getOrNull()
}
try {
val connection = URL(url.toString()).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
// The WebView's own headers go along: Mapbox checks a URL-restricted token
// against the Referer, and the server's origin is what the token allows.
for ((name, value) in forwardedHeaders(request.requestHeaders)) connection.setRequestProperty(name, value)
try {
val code = connection.responseCode
if (code == 200) {
val bytes = connection.inputStream.use { it.readBytes() }
if (bytes.size <= MAX_RESOURCE_BYTES) {
val type = connection.contentType ?: "application/octet-stream"
store(body, meta, bytes, type, now)
return response(bytes, type)
}
}
// Anything but a tile (401/403 for a token, 404, a 5xx) the WebView asks for
// itself, with everything a browser sends, and the map reports what it gets.
} finally {
connection.disconnect()
}
} catch (e: Exception) {
// Offline and never seen: the map shows the gap as it would in a browser.
}
return null
}
/** Headers that are the request's own; the connection sets the hop-by-hop ones itself. */
private val HOP_BY_HOP = setOf("connection", "keep-alive", "transfer-encoding", "te", "upgrade", "host", "content-length", "accept-encoding")
internal fun forwardedHeaders(headers: Map<String, String>?): Map<String, String> =
headers.orEmpty().filterKeys { it.lowercase() !in HOP_BY_HOP }
/** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */
private fun response(bytes: ByteArray, type: String): WebResourceResponse {
val mime = type.substringBefore(';').trim()
val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1)
return WebResourceResponse(mime, charset, 200, "OK", cors(), ByteArrayInputStream(bytes))
}
/** The map fetches from the server's origin; the store's answer has to allow it as Mapbox's own does. */
private fun cors(): MutableMap<String, String> = mutableMapOf("Access-Control-Allow-Origin" to "*", "Cache-Control" to "no-store")
private fun created(meta: File): Long = runCatching { meta.readLines().first().toLong() }.getOrDefault(0L)
@Synchronized
private fun store(body: File, meta: File, bytes: ByteArray, type: String, now: Long) {
runCatching {
body.writeBytes(bytes)
meta.writeText("$now\n$type")
}
if (++writesSincePrune >= 200) {
writesSincePrune = 0
prune()
}
}
/** Drops what is past 30 days, then the least recently used while the store is over its limit. */
@Synchronized
fun prune() {
val root = dir ?: return
val now = System.currentTimeMillis()
val bodies = root.listFiles { f -> f.name.endsWith(".bin") }?.toMutableList() ?: return
bodies.filter { now - created(File(root, it.name.removeSuffix(".bin") + ".type")) > MAX_AGE_MS }.forEach {
remove(root, it)
bodies.remove(it)
}
var total = bodies.sumOf { it.length() }
if (total <= MAX_BYTES) return
val target = (MAX_BYTES * PRUNE_TARGET).toLong()
for (file in bodies.sortedBy { it.lastModified() }) {
if (total <= target) break
total -= file.length()
remove(root, file)
}
}
private fun remove(root: File, body: File) {
body.delete()
File(root, body.name.removeSuffix(".bin") + ".type").delete()
}
fun usage(): Long = dir?.listFiles()?.sumOf { it.length() } ?: 0L
@Synchronized
fun clear() {
dir?.listFiles()?.forEach { it.delete() }
}
}
@@ -1,146 +0,0 @@
package com.liketrek.trek.tripday
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.liketrek.trek.MainActivity
import com.liketrek.trek.R
import com.liketrek.trek.widget.TrekIcons
import com.liketrek.trek.widget.WidgetSnapshot
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
/**
* The travel day as a Live Update, the Android counterpart of the iOS Live
* Activity (TripDayLiveActivity.swift): an ongoing notification with the next
* stop, its time as the status-bar chip and a countdown, and the day's stops as
* a segmented progress bar. Android 16 promotes it to the top of the shade and
* the lock screen (ProgressStyle, POST_PROMOTED_NOTIFICATIONS); older versions
* show the same content as a quiet ongoing notification with a plain bar.
*
* The UI starts, updates and ends it (TrekShell.syncTripDay / endTripDay), and
* it lapses on its own at midnight, so a forgotten one never lingers.
*/
object TripDayNotification {
const val CHANNEL = "tripday"
private const val ID = 0x7d1
data class State(
val tripTitle: String,
val path: String,
val dayLabel: String,
val nextName: String,
val nextTime: String?,
/** Minutes after midnight of the next stop, for the countdown. */
val nextMinutes: Int?,
val done: Int,
val total: Int,
/** #rrggbb of each stop, for the segments; may be empty. */
val colors: List<String?> = emptyList(),
)
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL) != null) return
// Default importance: low would keep it out of the status bar chip and promotion.
val channel = NotificationChannel(CHANNEL, context.getString(R.string.push_channel_tripday), NotificationManager.IMPORTANCE_DEFAULT).apply {
description = context.getString(R.string.push_channel_tripday_description)
setSound(null, null)
enableVibration(false)
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
/** Whether it can be shown at all: notifications allowed, and the channel not switched off. */
fun allowed(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
return false
}
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled()) return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = context.getSystemService(NotificationManager::class.java)?.getNotificationChannel(CHANNEL)
if (channel != null && channel.importance == NotificationManager.IMPORTANCE_NONE) return false
}
return true
}
/** Shows the day, or updates the one showing. False when notifications are off. */
fun show(context: Context, state: State): Boolean {
ensureChannel(context)
if (!allowed(context)) return false
try {
NotificationManagerCompat.from(context).notify(ID, build(context, state))
} catch (e: SecurityException) {
return false
}
return true
}
fun end(context: Context) {
NotificationManagerCompat.from(context).cancel(ID)
}
fun build(context: Context, state: State, today: LocalDate = LocalDate.now()): android.app.Notification {
val open = Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link(state.path)), context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val tap = PendingIntent.getActivity(context, ID, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val zone = ZoneId.systemDefault()
val midnight = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val remaining = (midnight - System.currentTimeMillis()).coerceAtLeast(60_000L)
val builder = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_trek)
.setContentTitle(state.nextName)
.setContentText(listOfNotNull(state.nextTime, state.tripTitle).joinToString(" "))
.setSubText(state.dayLabel)
.setContentIntent(tap)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setTimeoutAfter(remaining)
.setRequestPromotedOngoing(true)
state.nextTime?.let { builder.setShortCriticalText(it) }
state.nextMinutes?.let { minutes ->
val at = today.atTime(LocalTime.of((minutes / 60).coerceIn(0, 23), minutes % 60)).atZone(zone).toInstant().toEpochMilli()
if (at > System.currentTimeMillis()) {
builder.setWhen(at).setShowWhen(true).setUsesChronometer(true).setChronometerCountDown(true)
}
}
val total = state.total.coerceAtLeast(1)
val done = state.done.coerceIn(0, total)
val style = NotificationCompat.ProgressStyle()
.setStyledByProgress(true)
.setProgressTrackerIcon(IconCompat.createWithBitmap(TrekIcons.bitmap(TrekIcons.vector("figure.walk"), 72, ContextCompat.getColor(context, R.color.notification_accent))))
for (i in 0 until total) {
val color = state.colors.getOrNull(i)?.let(::parseColor) ?: ContextCompat.getColor(context, R.color.tripday_segment)
style.addProgressSegment(NotificationCompat.ProgressStyle.Segment(1).setColor(color))
}
style.setProgress(done)
builder.setStyle(style)
// Before Android 16 the style falls back to nothing: the plain bar carries the progress.
if (Build.VERSION.SDK_INT < 36) builder.setProgress(total, done, false)
return builder.build()
}
private fun parseColor(css: String): Int? = runCatching { android.graphics.Color.parseColor(css) }.getOrNull()
}
@@ -1,53 +0,0 @@
package com.liketrek.trek.ui
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
/**
* The theme of the app's own native screens (the share sheet, the widget's trip
* picker): Material You's wallpaper colours on Android 12 and later, TREK's
* neutral palette before that, light and dark with the system.
*/
@Composable
fun TrekMaterialTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val context = LocalContext.current
val colors = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dark -> darkColorScheme(
primary = Color(0xFFE4E4E7),
onPrimary = Color(0xFF101013),
secondary = Color(0xFF8AAEF0),
secondaryContainer = Color(0xFF233250),
onSecondaryContainer = Color(0xFFDCE6FA),
surface = Color(0xFF0C0C0F),
surfaceContainerLow = Color(0xFF16161A),
surfaceContainer = Color(0xFF1B1B21),
surfaceContainerHigh = Color(0xFF232329),
onSurface = Color(0xFFF5F5F7),
onSurfaceVariant = Color(0xFF9C9CA3),
)
else -> lightColorScheme(
primary = Color(0xFF111827),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF4A7DDB),
secondaryContainer = Color(0xFFE4EAF7),
onSecondaryContainer = Color(0xFF1B2F57),
surface = Color(0xFFFDFCF9),
surfaceContainerLow = Color(0xFFF4F2EE),
surfaceContainer = Color(0xFFEFEDE8),
surfaceContainerHigh = Color(0xFFE9E7E1),
onSurface = Color(0xFF101013),
onSurfaceVariant = Color(0xFF68686F),
)
}
MaterialTheme(colorScheme = colors, content = content)
}
@@ -1,209 +0,0 @@
package com.liketrek.trek.widget
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AcUnit
import androidx.compose.material.icons.rounded.AccountBalance
import androidx.compose.material.icons.rounded.Backpack
import androidx.compose.material.icons.rounded.BeachAccess
import androidx.compose.material.icons.rounded.Bed
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Bookmark
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.CameraAlt
import androidx.compose.material.icons.rounded.Chat
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.CreditCard
import androidx.compose.material.icons.rounded.DirectionsBoat
import androidx.compose.material.icons.rounded.DirectionsBus
import androidx.compose.material.icons.rounded.DirectionsCar
import androidx.compose.material.icons.rounded.DirectionsRun
import androidx.compose.material.icons.rounded.DirectionsWalk
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.FitnessCenter
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Flight
import androidx.compose.material.icons.rounded.FlightTakeoff
import androidx.compose.material.icons.rounded.Dehaze
import androidx.compose.material.icons.rounded.Forest
import androidx.compose.material.icons.rounded.Grain
import androidx.compose.material.icons.rounded.Group
import androidx.compose.material.icons.rounded.Hiking
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Hotel
import androidx.compose.material.icons.rounded.Landscape
import androidx.compose.material.icons.rounded.LocalBar
import androidx.compose.material.icons.rounded.LocalCafe
import androidx.compose.material.icons.rounded.LocalHospital
import androidx.compose.material.icons.rounded.LocalLibrary
import androidx.compose.material.icons.rounded.Luggage
import androidx.compose.material.icons.rounded.Map
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Navigation
import androidx.compose.material.icons.rounded.Park
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Poll
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Restaurant
import androidx.compose.material.icons.rounded.Sailing
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.ShoppingBag
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Storefront
import androidx.compose.material.icons.rounded.Thunderstorm
import androidx.compose.material.icons.rounded.TheaterComedy
import androidx.compose.material.icons.rounded.ConfirmationNumber
import androidx.compose.material.icons.rounded.Train
import androidx.compose.material.icons.rounded.Water
import androidx.compose.material.icons.rounded.WbCloudy
import androidx.compose.material.icons.rounded.WbSunny
import androidx.compose.material.icons.rounded.Cabin
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.VectorGroup
import androidx.compose.ui.graphics.vector.VectorNode
import androidx.compose.ui.graphics.vector.VectorPath
import androidx.compose.ui.graphics.vector.PathParser
import androidx.glance.ImageProvider
/**
* The widgets' icons: the Material Symbols (rounded) counterparts of the SF
* Symbols the snapshot names, so one snapshot draws on both platforms. A widget
* is RemoteViews, which cannot draw a Compose vector, so each icon is drawn
* once into a small white bitmap and tinted by the widget's theme colour.
*/
object TrekIcons {
/** The SF Symbol names client/src/native/widgetExtras.ts and widgets.ts use, and a few of the widgets' own. */
private val SYMBOLS: Map<String, ImageVector> = mapOf(
"airplane" to Icons.Rounded.Flight,
"airplane.departure" to Icons.Rounded.FlightTakeoff,
"tram.fill" to Icons.Rounded.Train,
"bus.fill" to Icons.Rounded.DirectionsBus,
"car.fill" to Icons.Rounded.DirectionsCar,
"ferry.fill" to Icons.Rounded.DirectionsBoat,
"sailboat.fill" to Icons.Rounded.Sailing,
"bed.double.fill" to Icons.Rounded.Hotel,
"house.fill" to Icons.Rounded.Home,
"tent.fill" to Icons.Rounded.Cabin,
"fork.knife" to Icons.Rounded.Restaurant,
"cup.and.saucer.fill" to Icons.Rounded.LocalCafe,
"mug.fill" to Icons.Rounded.LocalCafe,
"wineglass.fill" to Icons.Rounded.LocalBar,
"ticket.fill" to Icons.Rounded.ConfirmationNumber,
"theatermasks.fill" to Icons.Rounded.TheaterComedy,
"music.note" to Icons.Rounded.MusicNote,
"figure.hiking" to Icons.Rounded.Hiking,
"figure.walk" to Icons.Rounded.DirectionsWalk,
"figure.run" to Icons.Rounded.DirectionsRun,
"dumbbell.fill" to Icons.Rounded.FitnessCenter,
"bookmark.fill" to Icons.Rounded.Bookmark,
"building.columns.fill" to Icons.Rounded.AccountBalance,
"building.2.fill" to Icons.Rounded.AccountBalance,
"books.vertical.fill" to Icons.Rounded.LocalLibrary,
"storefront.fill" to Icons.Rounded.Storefront,
"bag.fill" to Icons.Rounded.ShoppingBag,
"backpack.fill" to Icons.Rounded.Backpack,
"suitcase.rolling.fill" to Icons.Rounded.Luggage,
"creditcard.fill" to Icons.Rounded.CreditCard,
"cross.case.fill" to Icons.Rounded.LocalHospital,
"camera.fill" to Icons.Rounded.CameraAlt,
"camera.macro" to Icons.Rounded.Park,
"leaf.fill" to Icons.Rounded.Park,
"tree.fill" to Icons.Rounded.Forest,
"mountain.2.fill" to Icons.Rounded.Landscape,
"water.waves" to Icons.Rounded.Water,
"beach.umbrella.fill" to Icons.Rounded.BeachAccess,
"sun.max.fill" to Icons.Rounded.WbSunny,
"bolt.fill" to Icons.Rounded.Bolt,
"heart.fill" to Icons.Rounded.Favorite,
"star.fill" to Icons.Rounded.Star,
"flag.fill" to Icons.Rounded.Flag,
"map.fill" to Icons.Rounded.Map,
"safari.fill" to Icons.Rounded.Navigation,
"location.north.fill" to Icons.Rounded.Navigation,
"location.fill" to Icons.Rounded.Place,
"globe.europe.africa.fill" to Icons.Rounded.Public,
"calendar" to Icons.Rounded.CalendarMonth,
"clock" to Icons.Rounded.Schedule,
"checklist" to Icons.Rounded.Checklist,
"checkmark.circle.fill" to Icons.Rounded.CheckCircle,
"person.2.fill" to Icons.Rounded.Group,
"bubble.left.fill" to Icons.Rounded.Chat,
"chart.bar.fill" to Icons.Rounded.Poll,
"event" to Icons.Rounded.Event,
)
/** A booking kind's symbol and colour, the same pairs as TrekStyle.swift. */
fun booking(kind: String): Pair<String, Int> = when (kind) {
"flight" -> "airplane" to 0xFF4A7DDB.toInt()
"train" -> "tram.fill" to 0xFF2FA37A.toInt()
"bus" -> "bus.fill" to 0xFF2BA6A6.toInt()
"car" -> "car.fill" to 0xFFD6673B.toInt()
"cruise", "ferry", "boat" -> "ferry.fill" to 0xFF2E8BC0.toInt()
"hotel" -> "bed.double.fill" to 0xFF8E6CD9.toInt()
"restaurant" -> "fork.knife" to 0xFFE8A13A.toInt()
"event" -> "ticket.fill" to 0xFFD6408B.toInt()
"tour" -> "figure.hiking" to 0xFF4E9F3D.toInt()
else -> "bookmark.fill" to 0xFF7A7A85.toInt()
}
/** The provider's main weather condition as an icon. */
fun weather(main: String): ImageVector = when (main.lowercase()) {
"clear" -> Icons.Rounded.WbSunny
"clouds" -> Icons.Rounded.WbCloudy
"rain", "drizzle" -> Icons.Rounded.Grain
"thunderstorm" -> Icons.Rounded.Thunderstorm
"snow" -> Icons.Rounded.AcUnit
"mist", "fog", "haze" -> Icons.Rounded.Dehaze
else -> Icons.Rounded.Cloud
}
fun vector(symbol: String?): ImageVector = symbol?.let { SYMBOLS[it] } ?: Icons.Rounded.Place
private val cache = HashMap<Pair<ImageVector, Int>, Bitmap>()
/** The icon as a white bitmap of [sizePx], tinted where it is drawn. */
@Synchronized
fun bitmap(vector: ImageVector, sizePx: Int = 48): Bitmap = cache.getOrPut(vector to sizePx) { render(vector, sizePx) }
fun provider(symbol: String?, sizePx: Int = 48): ImageProvider = ImageProvider(bitmap(vector(symbol), sizePx))
fun provider(vector: ImageVector, sizePx: Int = 48): ImageProvider = ImageProvider(bitmap(vector, sizePx))
/** The icon in [color], for places that draw it as it is (a notification's progress tracker). */
fun bitmap(vector: ImageVector, sizePx: Int, color: Int): Bitmap = render(vector, sizePx, color)
private fun render(vector: ImageVector, sizePx: Int, color: Int = android.graphics.Color.WHITE): Bitmap {
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val scale = Matrix().apply { setScale(sizePx / vector.viewportWidth, sizePx / vector.viewportHeight) }
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.color = color
style = Paint.Style.FILL
}
fun draw(node: VectorNode) {
when (node) {
is VectorGroup -> node.forEach { draw(it) }
is VectorPath -> {
val path: Path = PathParser().addPathNodes(node.pathData).toPath().asAndroidPath()
path.fillType = if (node.pathFillType == androidx.compose.ui.graphics.PathFillType.EvenOdd) {
Path.FillType.EVEN_ODD
} else {
Path.FillType.WINDING
}
path.transform(scale)
canvas.drawPath(path, paint)
}
}
}
draw(vector.root)
return bitmap
}
}
@@ -1,100 +0,0 @@
package com.liketrek.trek.widget
import android.content.Context
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.updateAll
import kotlin.reflect.KClass
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** The widget providers the manifest lists, one per widget in the picker. */
abstract class TrekWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
WidgetStore.forget(context, appWidgetIds)
}
}
class TripWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TripWidget()
}
class TodayWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
}
class BookingsWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = BookingsWidget()
}
class TodosWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodosWidget()
}
class PackingWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = PackingWidget()
}
class AtlasWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AtlasWidget()
}
class CollabWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = CollabWidget()
}
/** Redraws every placed TREK widget after the app wrote a new snapshot. */
object TrekWidgetUpdater {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val receivers: List<KClass<out GlanceAppWidgetReceiver>> = listOf(
TripWidgetReceiver::class,
TodayWidgetReceiver::class,
BookingsWidgetReceiver::class,
TodosWidgetReceiver::class,
PackingWidgetReceiver::class,
AtlasWidgetReceiver::class,
CollabWidgetReceiver::class,
)
/** The widget a placed widget id belongs to, by its provider. */
fun widgetFor(context: Context, appWidgetId: Int): GlanceAppWidget? {
val provider = android.appwidget.AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider?.className
?: return null
val receiver = receivers.firstOrNull { it.java.name == provider } ?: return null
return receiver.java.getDeclaredConstructor().newInstance().glanceAppWidget
}
fun widgets(): List<GlanceAppWidget> =
listOf(TripWidget(), TodayWidget(), BookingsWidget(), TodosWidget(), PackingWidget(), AtlasWidget(), CollabWidget())
@JvmStatic
fun refreshAll(context: Context) {
val app = context.applicationContext
scope.launch { for (widget in widgets()) runCatching { widget.updateAll(app) } }
}
/**
* Android 15's widget picker shows a live preview drawn by the widget itself
* (providePreview) instead of a static image. The system rate-limits the
* call, so it runs once per app version.
*/
@JvmStatic
fun publishPreviews(context: Context) {
if (Build.VERSION.SDK_INT < 35) return
val app = context.applicationContext
val prefs = app.getSharedPreferences("trek.widgets", Context.MODE_PRIVATE)
val version = runCatching { app.packageManager.getPackageInfo(app.packageName, 0).lastUpdateTime }.getOrDefault(0L)
if (prefs.getLong("previews", -1L) == version) return
scope.launch {
val manager = GlanceAppWidgetManager(app)
val all = receivers.all { runCatching { manager.setWidgetPreviews(it); true }.getOrDefault(false) }
if (all) prefs.edit().putLong("previews", version).apply()
}
}
}
@@ -1,815 +0,0 @@
package com.liketrek.trek.widget
import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Flight
import androidx.compose.material.icons.rounded.Group
import androidx.compose.material.icons.rounded.Luggage
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Public
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.ColumnScope
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import java.time.LocalDate
/** The sizes every TREK widget lays itself out for (a 2x2, a 4x2 and a 4x4 cell block). */
object TrekSizes {
val SMALL = DpSize(120.dp, 120.dp)
val WIDE = DpSize(250.dp, 120.dp)
val LARGE = DpSize(250.dp, 260.dp)
val all = setOf(SMALL, WIDE, LARGE)
}
private val DpSize.isWide get() = width >= 220.dp
private val DpSize.isTall get() = height >= 220.dp
/**
* A TREK widget: reads the snapshot of the trip it is set to (WidgetStore) and
* draws [Content]. The app writes the snapshot and refreshes the widgets
* (TrekWidgetUpdater); [preview] draws the same views from a sample trip for
* the widget picker and the screenshot tests.
*/
abstract class TrekWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Responsive(TrekSizes.all)
override val previewSizeMode = SizeMode.Responsive(TrekSizes.all)
/** Whether the widget shows one trip, so the trip picker applies to it. */
open val perTrip: Boolean = true
override suspend fun provideGlance(context: Context, id: GlanceId) {
val appWidgetId = runCatching { GlanceAppWidgetManager(context).getAppWidgetId(id) }.getOrDefault(-1)
provideContent {
// Read inside the composition: a session outlives one update, and every
// write (the dashboard's, then each trip's) must reach it.
val version by WidgetStore.version.collectAsState()
val snapshot = remember(version) {
val tripId = if (perTrip && appWidgetId >= 0) WidgetStore.tripFor(context, appWidgetId) else WidgetStore.AUTO
WidgetStore.load(context, tripId)
}
TrekWidgetTheme.Themed { Content(snapshot) }
}
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
provideContent { TrekWidgetTheme.Themed { Content(WidgetSamples.snapshot(running = false)) } }
}
@Composable
abstract fun Content(snapshot: WidgetSnapshot?)
}
// ---- Trip ----
/** The trip: a countdown on its own cover before it starts, the day and what comes next while it runs. */
class TripWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val trip = snapshot?.trip
if (snapshot == null || trip == null) {
EmptyWidget(snapshot?.labels?.noTrip ?: WidgetSamples.emptyText(context))
return
}
val size = LocalSize.current
WidgetFrame(onClick = openPath(context, trip.path), background = TrekStatus.scrim, padding = 0.dp) {
Box(modifier = GlanceModifier.fillMaxSize()) {
Image(
provider = ImageProvider(WidgetArt.cover(trip.cover) ?: WidgetArt.sky(trip.title)),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize(),
)
Image(
provider = ImageProvider(com.liketrek.trek.R.drawable.widget_scrim),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = GlanceModifier.fillMaxSize(),
)
Column(modifier = GlanceModifier.fillMaxSize().padding(14.dp)) {
val today = snapshot.today
if (today != null) Running(snapshot, trip, today, size) else Upcoming(snapshot, trip, size)
}
}
}
}
@Composable
private fun ColumnScope.Upcoming(snapshot: WidgetSnapshot, trip: WidgetSnapshot.Trip, size: DpSize) {
val context = LocalContext.current
val labels = snapshot.labels
val days = (snapshot.daysUntilStart() ?: 0L).coerceAtLeast(0L)
if (!size.isWide) {
// Small: the number carries it, with the trip's name under it.
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Flight, TrekStatus.white, 16.dp)
Spacer(GlanceModifier.defaultWeight())
snapshot.weather?.let { WeatherChip(it, onPhoto = true) }
}
Spacer(GlanceModifier.defaultWeight())
Countdown(days, labels, ring = false)
Line(trip.title, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 1)
return
}
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = GlanceModifier.defaultWeight()) {
Eyebrow(labels.nextTrip, TrekStatus.whiteMuted)
Spacer(GlanceModifier.height(2.dp))
Line(trip.title, if (size.isTall) 22.sp else 18.sp, TrekStatus.white, FontWeight.Bold, maxLines = 2)
trip.place?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Place, TrekStatus.whiteMuted, 12.dp)
Spacer(GlanceModifier.width(3.dp))
Line(it, 12.sp, TrekStatus.whiteMuted, FontWeight.Medium)
}
}
if (size.isTall) {
RelativeDay.range(context, trip.start, trip.end)?.let {
Spacer(GlanceModifier.height(6.dp))
Chip(it, TrekStatus.glass, TrekStatus.white, TrekIcons.provider(Icons.Rounded.CalendarMonth), TrekStatus.white)
}
}
}
Spacer(GlanceModifier.width(10.dp))
Countdown(days, labels, ring = true)
}
if (size.isTall) {
Spacer(GlanceModifier.defaultWeight())
TripFacts(snapshot, onPhoto = true)
snapshot.bookings.firstOrNull()?.let {
Spacer(GlanceModifier.height(8.dp))
BookingRow(it, labels, onPhoto = true)
}
}
}
/** The days to go: a big number, or today and tomorrow in words; around a ring of the last 60 days on the wide sizes. */
@Composable
private fun Countdown(days: Long, labels: WidgetSnapshot.Labels, ring: Boolean) {
val number: @Composable () -> Unit = {
when (days) {
0L -> Line(labels.todayShort, 26.sp, TrekStatus.white, FontWeight.Bold)
1L -> Line(labels.tomorrow, 20.sp, TrekStatus.white, FontWeight.Bold)
else -> if (ring) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = days.toString(),
style = TextStyle(color = TrekStatus.white, fontSize = if (days > 99) 22.sp else 28.sp, fontWeight = FontWeight.Bold),
maxLines = 1,
)
Eyebrow(labels.days, TrekStatus.whiteMuted)
}
} else {
Row(verticalAlignment = Alignment.Bottom) {
Text(days.toString(), style = TextStyle(color = TrekStatus.white, fontSize = 34.sp, fontWeight = FontWeight.Bold), maxLines = 1)
Spacer(GlanceModifier.width(4.dp))
Line(labels.days, 13.sp, TrekStatus.whiteMuted, FontWeight.Bold, modifier = GlanceModifier.padding(bottom = 6.dp))
}
}
}
}
if (!ring) {
number()
return
}
val progress = 1f - (days.coerceAtMost(60L).toFloat() / 60f)
Box(modifier = GlanceModifier.size(84.dp), contentAlignment = Alignment.Center) {
Image(
provider = ImageProvider(WidgetArt.ring(progress)),
contentDescription = null,
colorFilter = ColorFilter.tint(TrekStatus.white),
modifier = GlanceModifier.fillMaxSize(),
)
number()
}
}
@Composable
private fun ColumnScope.Running(snapshot: WidgetSnapshot, trip: WidgetSnapshot.Trip, today: WidgetSnapshot.Today, size: DpSize) {
val labels = snapshot.labels
val now = nowMinutes()
val next = today.stops.firstOrNull { (it.minutes ?: -1) >= now } ?: today.stops.lastOrNull()
val index = today.index ?: 1
val total = today.total ?: snapshot.tripLength ?: 1
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Eyebrow(if (size.isWide) trip.title else today.dayLabel, TrekStatus.whiteMuted, GlanceModifier.defaultWeight())
snapshot.weather?.let { WeatherChip(it, onPhoto = true) }
}
if (size.isWide) Line(today.dayLabel, 20.sp, TrekStatus.white, FontWeight.Bold, modifier = GlanceModifier.padding(top = 2.dp))
Spacer(GlanceModifier.height(8.dp))
DaySegments(index, total, TrekStatus.white)
Spacer(GlanceModifier.defaultWeight())
if (size.isWide) {
Column(modifier = GlanceModifier.fillMaxWidth().background(TrekStatus.glass).cornerRadius(16.dp).padding(10.dp)) {
StopList(today.stops, now, limit = if (size.isTall) 5 else 1, onPhoto = true, nothing = labels.nothingPlanned, nowLabel = labels.now)
}
} else if (next != null) {
Line(next.time ?: labels.today, 26.sp, TrekStatus.white, FontWeight.Bold)
Line(next.name, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 2)
} else {
Line(labels.nothingPlanned, 14.sp, TrekStatus.white, FontWeight.Bold, maxLines = 3)
}
}
}
/** The trip's numbers: days, places, travellers. */
@Composable
private fun TripFacts(snapshot: WidgetSnapshot, onPhoto: Boolean) {
val labels = snapshot.labels
val facts = listOfNotNull(
snapshot.tripLength?.let { it to labels.days },
snapshot.trip?.placeCount?.let { it to labels.places },
snapshot.trip?.memberCount?.let { it to labels.travellers },
)
Row(modifier = GlanceModifier.fillMaxWidth()) {
facts.forEachIndexed { i, (value, label) ->
Column(
modifier = GlanceModifier.defaultWeight().background(if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer)
.cornerRadius(14.dp).padding(horizontal = 10.dp, vertical = 6.dp),
) {
Line(value.toString(), 18.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold)
Line(label, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSecondaryContainer)
}
if (i < facts.size - 1) Spacer(GlanceModifier.width(6.dp))
}
}
}
// ---- Today ----
/** Today's plan as a timeline, with the weather and what is next. */
class TodayWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels
val today = snapshot?.today
val trip = snapshot?.trip
if (snapshot == null || labels == null || trip == null) {
EmptyWidget(labels?.noTrip ?: WidgetSamples.emptyText(context))
return
}
WidgetFrame(onClick = openPath(context, trip.path)) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = GlanceModifier.defaultWeight()) {
if (size.isTall) Eyebrow(if (today != null) labels.today else labels.nextTrip, GlanceTheme.colors.primary)
Line(today?.dayLabel ?: trip.title, if (size.isWide) 17.sp else 14.sp, weight = FontWeight.Bold)
}
snapshot.weather?.let { WeatherChip(it, onPhoto = false) }
}
Spacer(GlanceModifier.height(if (size.isTall) 10.dp else 6.dp))
if (today == null || today.stops.isEmpty()) {
Spacer(GlanceModifier.defaultWeight())
val days = snapshot.daysUntilStart()
val text = if (today == null && days != null && days > 0) labels.inDays.replace("{n}", days.toString()) else labels.nothingPlanned
Line(text, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 3)
Spacer(GlanceModifier.defaultWeight())
} else {
val limit = when {
size.isTall -> 6
size.isWide -> 2
else -> 1
}
StopList(today.stops, nowMinutes(), limit, onPhoto = false, nothing = labels.nothingPlanned, nowLabel = labels.now, compact = !size.isWide)
}
}
}
}
}
/** The stops from the next one on, at most [limit], the next one marked. */
@Composable
fun StopList(
stops: List<WidgetSnapshot.Stop>,
nowMinutes: Int,
limit: Int,
onPhoto: Boolean,
nothing: String,
nowLabel: String,
compact: Boolean = false,
) {
if (stops.isEmpty()) {
Line(nothing, 13.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurfaceVariant)
return
}
val nextIndex = stops.indexOfFirst { (it.minutes ?: -1) >= nowMinutes }.let { if (it < 0) stops.size - 1 else it }
// Keep the last done stop in view when there is room, so the day reads as a line.
val from = (nextIndex - if (limit > 3) 1 else 0).coerceIn(0, (stops.size - limit).coerceAtLeast(0))
val shown = stops.drop(from).take(limit)
shown.forEachIndexed { i, stop ->
val index = from + i
val isNext = index == nextIndex
val done = index < nextIndex
val ink = when {
onPhoto -> if (done) TrekStatus.whiteFaint else TrekStatus.white
done -> GlanceTheme.colors.onSurfaceVariant
else -> GlanceTheme.colors.onSurface
}
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
val accent = cssColor(stop.color) ?: if (onPhoto) TrekStatus.white else GlanceTheme.colors.primary
if (compact) {
Box(modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp).background(accent)) {}
} else {
IconBadge(
stop.symbol,
tint = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer,
background = if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer,
size = 26.dp,
)
}
Spacer(GlanceModifier.width(8.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Line(stop.name, 13.sp, ink, if (isNext) FontWeight.Bold else FontWeight.Medium)
if (compact && stop.time != null) Line(stop.time, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant)
}
if (!compact) {
if (isNext && !onPhoto) {
Chip(stop.time ?: nowLabel, GlanceTheme.colors.primary, GlanceTheme.colors.onPrimary)
} else {
Line(stop.time ?: "", 12.sp, ink, if (isNext) FontWeight.Bold else FontWeight.Normal)
}
}
}
}
}
// ---- Bookings ----
/** The next flights, trains, hotels and tables, and when they are due. */
class BookingsWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
val bookings = snapshot.bookings
WidgetFrame(onClick = openPath(context, bookings.firstOrNull()?.path ?: snapshot.trip?.path ?: "/")) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 14.dp)
Spacer(GlanceModifier.width(5.dp))
Eyebrow(labels.bookings, GlanceTheme.colors.primary)
}
Spacer(GlanceModifier.height(8.dp))
if (bookings.isEmpty()) {
Spacer(GlanceModifier.defaultWeight())
Line(labels.noBookings, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2)
Spacer(GlanceModifier.defaultWeight())
} else if (!size.isWide) {
val first = bookings.first()
val (symbol, color) = TrekIcons.booking(first.kind)
IconBadge(symbol, TrekStatus.white, ColorProvider(androidx.compose.ui.graphics.Color(color)), 30.dp)
Spacer(GlanceModifier.defaultWeight())
Line(first.title, 14.sp, weight = FontWeight.Bold, maxLines = 1)
Line(
listOfNotNull(RelativeDay.label(first.date, labels), first.time).joinToString(", "),
12.sp,
GlanceTheme.colors.onSurfaceVariant,
)
} else {
val limit = if (size.isTall) 4 else 1
bookings.take(limit).forEach { booking ->
BookingRow(booking, labels, onPhoto = false)
Spacer(GlanceModifier.height(5.dp))
}
}
}
}
}
}
@Composable
private fun BookingRow(booking: WidgetSnapshot.Booking, labels: WidgetSnapshot.Labels, onPhoto: Boolean) {
val context = LocalContext.current
val (symbol, color) = TrekIcons.booking(booking.kind)
Row(
modifier = GlanceModifier.fillMaxWidth()
.background(if (onPhoto) TrekStatus.glass else GlanceTheme.colors.surfaceVariant)
.cornerRadius(14.dp).padding(horizontal = 8.dp, vertical = 6.dp)
.clickable(openPath(context, booking.path)),
verticalAlignment = Alignment.CenterVertically,
) {
IconBadge(symbol, TrekStatus.white, ColorProvider(androidx.compose.ui.graphics.Color(color)), 30.dp)
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Line(booking.title, 13.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurface, FontWeight.Bold)
booking.detail?.let { Line(it, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant) }
}
Spacer(GlanceModifier.width(6.dp))
Column(horizontalAlignment = Alignment.End) {
RelativeDay.label(booking.date, labels)?.let { Line(it, 12.sp, if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSurface, FontWeight.Bold) }
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = GlanceModifier.size(6.dp).cornerRadius(3.dp).background(if (booking.confirmed) TrekStatus.confirmed else TrekStatus.pending)) {}
Spacer(GlanceModifier.width(4.dp))
Line(booking.time ?: if (booking.confirmed) labels.confirmed else labels.pending, 11.sp, if (onPhoto) TrekStatus.whiteMuted else GlanceTheme.colors.onSurfaceVariant)
}
}
}
}
// ---- To-dos ----
/** What is still open for the trip, and how far the packing has come. */
class TodosWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
val todos = snapshot.todos
WidgetFrame(onClick = openPath(context, todos?.path ?: snapshot.trip?.path ?: "/")) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.CheckCircle, GlanceTheme.colors.primary, 14.dp)
Spacer(GlanceModifier.width(5.dp))
Eyebrow(labels.todos, GlanceTheme.colors.primary, GlanceModifier.defaultWeight())
if (todos != null && todos.total > 0) Line("${todos.total - todos.open}/${todos.total}", 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold)
}
Spacer(GlanceModifier.height(8.dp))
if (todos == null || todos.open == 0) {
Spacer(GlanceModifier.defaultWeight())
Line(labels.noTodos, 16.sp, weight = FontWeight.Bold)
snapshot.trip?.title?.let { Line(it, 12.sp, GlanceTheme.colors.onSurfaceVariant) }
Spacer(GlanceModifier.defaultWeight())
} else {
if (!size.isWide) {
Line(todos.open.toString(), 34.sp, GlanceTheme.colors.primary, FontWeight.Bold)
Spacer(GlanceModifier.defaultWeight())
}
val limit = when {
size.isTall -> 6
size.isWide -> 3
else -> 1
}
Column(modifier = GlanceModifier.fillMaxWidth()) {
todos.items.take(limit).forEach { todo ->
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = GlanceModifier.size(14.dp).cornerRadius(7.dp).background(priorityColor(todo.priority) ?: GlanceTheme.colors.outline),
contentAlignment = Alignment.Center,
) {
Box(modifier = GlanceModifier.size(10.dp).cornerRadius(5.dp).background(GlanceTheme.colors.widgetBackground)) {}
}
Spacer(GlanceModifier.width(8.dp))
Line(todo.name, 13.sp, modifier = GlanceModifier.defaultWeight())
if (size.isWide) RelativeDay.label(todo.due, labels)?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium) }
}
}
}
if (size.isWide) Spacer(GlanceModifier.defaultWeight())
}
val packing = snapshot.packing
if (size.isTall && packing != null && packing.total > 0) {
Spacer(GlanceModifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Luggage, GlanceTheme.colors.onSurfaceVariant, 13.dp)
Spacer(GlanceModifier.width(5.dp))
Line("${labels.packing}: ${packing.packed}/${packing.total}", 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium)
}
Spacer(GlanceModifier.height(5.dp))
Bar(packing.packed.toFloat() / packing.total, GlanceTheme.colors.primary, GlanceTheme.colors.surfaceVariant)
}
}
}
}
private fun priorityColor(priority: Int): ColorProvider? = when (priority) {
3 -> TrekStatus.danger
2 -> TrekStatus.pending
1 -> ColorProvider(androidx.compose.ui.graphics.Color(0xFF4A7DDB))
else -> null
}
}
// ---- Packing ----
/** How full the bags are, their weight, and what is still missing. */
class PackingWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
val packing = snapshot.packing
WidgetFrame(onClick = openPath(context, packing?.path ?: snapshot.trip?.path ?: "/")) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Luggage, GlanceTheme.colors.primary, 14.dp)
Spacer(GlanceModifier.width(5.dp))
Eyebrow(labels.packing, GlanceTheme.colors.primary)
}
if (packing == null || packing.total == 0) {
Spacer(GlanceModifier.defaultWeight())
Line(snapshot.trip?.title ?: labels.noTrip, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 3)
Spacer(GlanceModifier.defaultWeight())
return@Column
}
val progress = packing.packed.toFloat() / packing.total
Spacer(GlanceModifier.height(if (size.isWide) 8.dp else 4.dp))
if (!size.isWide) {
// Small: the ring is the widget.
Box(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), contentAlignment = Alignment.Center) {
PackRing(progress, 64.dp)
}
Line("${packing.packed}/${packing.total} ${labels.packed}", 11.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium)
return@Column
}
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
PackRing(progress, 64.dp)
Spacer(GlanceModifier.width(12.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Line("${packing.packed}/${packing.total}", 18.sp, weight = FontWeight.Bold)
Line(labels.packed, 12.sp, GlanceTheme.colors.onSurfaceVariant)
if (!size.isTall && packing.missing.isNotEmpty()) {
Line("${packing.missing.size} ${labels.missing}", 12.sp, GlanceTheme.colors.primary, FontWeight.Medium)
}
}
}
if (size.isTall) {
Spacer(GlanceModifier.height(10.dp))
// Each group in a column of its own: a Glance column takes ten children at most.
Column(modifier = GlanceModifier.fillMaxWidth()) {
packing.bags.take(2).forEach { bag -> BagRow(bag) }
}
if (packing.missing.isNotEmpty()) {
Spacer(GlanceModifier.height(4.dp))
Column(modifier = GlanceModifier.fillMaxWidth()) {
Eyebrow(labels.missing)
Spacer(GlanceModifier.height(2.dp))
packing.missing.take(2).forEach { item ->
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = GlanceModifier.size(6.dp).cornerRadius(3.dp).background(cssColor(item.color) ?: GlanceTheme.colors.outline)) {}
Spacer(GlanceModifier.width(8.dp))
Line(item.name, 12.sp, modifier = GlanceModifier.defaultWeight())
item.category?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) }
}
}
}
}
}
}
}
}
@Composable
private fun PackRing(progress: Float, size: androidx.compose.ui.unit.Dp) {
Box(modifier = GlanceModifier.size(size), contentAlignment = Alignment.Center) {
Image(
provider = ImageProvider(WidgetArt.ring(progress, stroke = 16f)),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary),
modifier = GlanceModifier.fillMaxSize(),
)
Line("${(progress * 100).toInt()}%", 13.sp, weight = FontWeight.Bold)
}
}
@Composable
private fun BagRow(bag: WidgetSnapshot.Bag) {
val bagColor = cssColor(bag.color) ?: GlanceTheme.colors.primary
Column(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 6.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = GlanceModifier.size(8.dp).cornerRadius(4.dp).background(bagColor)) {}
Spacer(GlanceModifier.width(6.dp))
Line(bag.name, 12.sp, weight = FontWeight.Medium, modifier = GlanceModifier.defaultWeight())
Line(weight(bag.grams) + (bag.limit?.let { " / ${weight(it)}" } ?: ""), 11.sp, GlanceTheme.colors.onSurfaceVariant)
}
Spacer(GlanceModifier.height(4.dp))
Bar(if (bag.limit != null && bag.limit > 0) bag.grams.toFloat() / bag.limit else bag.packed.toFloat() / bag.total.coerceAtLeast(1), bagColor, GlanceTheme.colors.surfaceVariant, 5.dp)
}
}
private fun weight(grams: Int): String = if (grams >= 1000) "%.1f kg".format(grams / 1000f) else "$grams g"
}
// ---- Atlas ----
/** The countries the user has been to, glowing on the world map. Not tied to a trip. */
class AtlasWidget : TrekWidget() {
override val perTrip = false
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
val atlas = snapshot.atlas
val codes = atlas?.codes?.toSet() ?: emptySet()
WidgetFrame(onClick = openPath(context, "/atlas")) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Public, GlanceTheme.colors.primary, 14.dp)
Spacer(GlanceModifier.width(5.dp))
Eyebrow(context.getString(com.liketrek.trek.R.string.widget_atlas_name), GlanceTheme.colors.primary)
}
Spacer(GlanceModifier.height(4.dp))
val count: @Composable () -> Unit = {
Row(verticalAlignment = Alignment.Bottom) {
Line((atlas?.count ?: 0).toString(), if (size.isWide) 34.sp else 30.sp, GlanceTheme.colors.onSurface, FontWeight.Bold)
Spacer(GlanceModifier.width(6.dp))
Column(modifier = GlanceModifier.padding(bottom = 4.dp)) {
Line(labels.countries, 12.sp, GlanceTheme.colors.onSurface, FontWeight.Bold)
// 195 countries make the world, as on the Atlas page.
if (size.isWide) Line("${((atlas?.count ?: 0) * 100 / 195)}% ${labels.world}", 11.sp, GlanceTheme.colors.onSurfaceVariant)
}
}
}
if (size.isWide && !size.isTall) {
// Wide: the count beside the map, which gets the height.
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
count()
Spacer(GlanceModifier.width(10.dp))
WorldMap(codes, GlanceModifier.defaultWeight().fillMaxHeight())
}
} else {
count()
Spacer(GlanceModifier.height(6.dp))
WorldMap(codes, GlanceModifier.fillMaxWidth().defaultWeight())
}
if (size.isTall && atlas != null) {
Spacer(GlanceModifier.height(10.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
atlas.recent.take(4).forEach { country ->
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(flag(country.code), style = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center))
Line(country.name, 10.sp, GlanceTheme.colors.onSurfaceVariant)
}
}
}
}
}
}
}
@Composable
private fun WorldMap(codes: Set<String>, modifier: GlanceModifier) {
Box(modifier = modifier) {
Image(
provider = ImageProvider(WidgetArt.worldDots(codes, visited = false)),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.outline),
contentScale = ContentScale.Fit,
modifier = GlanceModifier.fillMaxSize(),
)
Image(
provider = ImageProvider(WidgetArt.worldDots(codes, visited = true)),
contentDescription = null,
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary),
contentScale = ContentScale.Fit,
modifier = GlanceModifier.fillMaxSize(),
)
}
}
/** The flag emoji of an ISO 3166-1 alpha-2 code. */
private fun flag(code: String): String {
if (code.length != 2) return ""
val first = Character.toChars(0x1F1E6 + (code[0].uppercaseChar() - 'A'))
val second = Character.toChars(0x1F1E6 + (code[1].uppercaseChar() - 'A'))
return String(first) + String(second)
}
}
// ---- Travel group ----
/** Who comes along, the latest messages and the open poll. */
class CollabWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
val collab = snapshot.collab
WidgetFrame(onClick = openPath(context, collab?.path ?: snapshot.trip?.path ?: "/")) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Group, GlanceTheme.colors.primary, 14.dp)
Spacer(GlanceModifier.width(5.dp))
if (size.isWide) Eyebrow(labels.group, GlanceTheme.colors.primary, GlanceModifier.defaultWeight()) else Spacer(GlanceModifier.defaultWeight())
collab?.members?.take(if (size.isWide) 4 else 3)?.forEach { member ->
Spacer(GlanceModifier.width(2.dp))
Initials(member.initials, 22.dp)
}
}
Spacer(GlanceModifier.height(8.dp))
val messages = collab?.messages.orEmpty()
if (messages.isEmpty() && collab?.poll == null) {
Spacer(GlanceModifier.defaultWeight())
Line(labels.noMessages, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2)
Spacer(GlanceModifier.defaultWeight())
return@Column
}
messages.takeLast(if (size.isTall) (if (collab?.poll != null) 2 else 4) else 1).forEach { message ->
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.Top) {
if (!message.mine) {
Initials(message.initials, 24.dp)
Spacer(GlanceModifier.width(6.dp))
} else {
Spacer(GlanceModifier.defaultWeight())
}
Column(
modifier = GlanceModifier
.background(if (message.mine) GlanceTheme.colors.primary else GlanceTheme.colors.surfaceVariant)
.cornerRadius(14.dp).padding(horizontal = 10.dp, vertical = 6.dp),
) {
if (!message.mine && size.isWide) Line(message.author, 10.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold)
Line(
message.text,
12.sp,
if (message.mine) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface,
maxLines = if (size.isWide) 2 else 3,
)
}
}
}
val poll = collab?.poll
if (size.isTall && poll != null) {
Spacer(GlanceModifier.defaultWeight())
Column(modifier = GlanceModifier.fillMaxWidth().background(GlanceTheme.colors.secondaryContainer).cornerRadius(14.dp).padding(10.dp)) {
Eyebrow("${labels.poll}: ${poll.voters} ${labels.votes}", GlanceTheme.colors.onSecondaryContainer)
Line(poll.question, 13.sp, GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold)
Spacer(GlanceModifier.height(4.dp))
val most = poll.options.maxOfOrNull { it.votes }?.coerceAtLeast(1) ?: 1
poll.options.take(3).forEach { option ->
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Line(option.label, 12.sp, GlanceTheme.colors.onSecondaryContainer, modifier = GlanceModifier.width(70.dp))
Bar(option.votes.toFloat() / most, GlanceTheme.colors.primary, GlanceTheme.colors.surface, 5.dp, GlanceModifier.defaultWeight())
Spacer(GlanceModifier.width(6.dp))
Line(option.votes.toString(), 11.sp, GlanceTheme.colors.onSecondaryContainer, FontWeight.Bold)
}
}
}
}
}
}
}
@Composable
private fun Initials(text: String, size: androidx.compose.ui.unit.Dp) {
Box(
modifier = GlanceModifier.size(size).cornerRadius(size / 2).background(GlanceTheme.colors.tertiaryContainer),
contentAlignment = Alignment.Center,
) {
Text(text, style = TextStyle(color = GlanceTheme.colors.onTertiaryContainer, fontSize = 9.sp, fontWeight = FontWeight.Bold), maxLines = 1)
}
}
}
/** A widget with nothing to show yet: TREK's mark and what to do. */
@Composable
fun EmptyWidget(text: String) {
val context = LocalContext.current
WidgetFrame(onClick = openPath(context, "/")) {
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = GlanceModifier.size(34.dp).cornerRadius(11.dp).background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
Text("T", style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 18.sp, fontWeight = FontWeight.Bold))
}
Spacer(GlanceModifier.height(10.dp))
Line(text, 13.sp, GlanceTheme.colors.onSurface, FontWeight.Medium, maxLines = 4)
}
}
}
@@ -1,152 +0,0 @@
package com.liketrek.trek.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RadialGradient
import android.graphics.Shader
import android.util.Base64
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
/**
* The pictures a widget shows that RemoteViews cannot draw: the trip's cover,
* its own sky when it has none, and the world map's dots. Drawn once per
* update into bitmaps, kept small (a widget's RemoteViews have a size budget).
*/
object WidgetArt {
/** A stable number for a text (FNV-1a), the same as TrekStyle.swift's seed. */
fun seed(text: String): Long {
var hash = 1469598103934665603L
for (byte in text.toByteArray(Charsets.UTF_8)) {
hash = (hash xor (byte.toLong() and 0xff)) * 1099511628211L
}
return hash
}
/** The trip's two colours, from its title: the same trip always gets the same sky. */
fun tripHues(title: String): Pair<Int, Int> {
val base = (java.lang.Long.remainderUnsigned(seed(title), 360)).toFloat()
val first = Color.HSVToColor(floatArrayOf(base, 0.62f, 0.78f))
val second = Color.HSVToColor(floatArrayOf((base + 40f) % 360f, 0.7f, 0.42f))
return first to second
}
/** The cover the app sent, base64 JPEG, decoded down to at most [maxSide] pixels. */
fun cover(base64: String?, maxSide: Int = 720): Bitmap? {
if (base64.isNullOrBlank()) return null
return try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= maxSide || bounds.outHeight / (sample * 2) >= maxSide) sample *= 2
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (e: IllegalArgumentException) {
null
}
}
/** A trip without a cover: its two colours as a sky, with contour lines over it. */
fun sky(title: String, width: Int = 480, height: Int = 480): Bitmap {
val (first, second) = tripHues(title)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.shader = LinearGradient(0f, 0f, width.toFloat(), height.toFloat(), first, second, Shader.TileMode.CLAMP)
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
paint.shader = RadialGradient(width * 0.85f, height * 0.05f, width * 0.6f, Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP)
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
paint.shader = null
drawContours(canvas, width, height, java.lang.Long.remainderUnsigned(seed(title), 997).toInt(), Color.argb(36, 255, 255, 255))
return bitmap
}
/** Concentric wobbly rings, the topographic lines that stand for travel in TREK. */
private fun drawContours(canvas: Canvas, width: Int, height: Int, seed: Int, color: Int) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2f
this.color = color
}
val cx = width * (0.25f + (seed % 50) / 100f)
val cy = height * (0.35f + (seed % 30) / 100f)
val maxR = min(width, height) * 1.1f
val rings = 11
for (ring in 1..rings) {
val r = maxR * ring / rings
val path = Path()
val steps = 72
for (i in 0..steps) {
val a = 2 * PI * i / steps
val wobble = 1 + 0.08 * sin(a * 3 + seed + ring) + 0.05 * cos(a * 5 + seed * 0.7)
val x = cx + (r * wobble * cos(a)).toFloat()
val y = cy + (r * wobble * sin(a) * 0.8).toFloat()
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
canvas.drawPath(path, paint)
}
}
/**
* The world map as dots, white so the widget tints it: [visited] true draws
* only the countries in [codes], false only the others.
*/
fun worldDots(codes: Set<String>, visited: Boolean, dot: Int = 8): Bitmap {
val rows = WorldGrid.ROWS.size
val bitmap = Bitmap.createBitmap(WorldGrid.COLUMNS * dot, rows * dot, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = Color.WHITE }
val radius = dot * 0.36f
for (row in 0 until rows) {
for (column in 0 until WorldGrid.COLUMNS) {
val code = WorldGrid.code(row, column) ?: continue
if ((code in codes) != visited) continue
canvas.drawCircle(column * dot + dot / 2f, row * dot + dot / 2f, radius, paint)
}
}
return bitmap
}
/** A ring of [progress] (0..1), white on a faint track, for the countdown and the packing. */
fun ring(progress: Float, sizePx: Int = 160, stroke: Float = 14f, track: Int = Color.argb(60, 255, 255, 255)): Bitmap {
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = stroke
strokeCap = Paint.Cap.ROUND
}
val inset = stroke / 2 + 1
val oval = android.graphics.RectF(inset, inset, sizePx - inset, sizePx - inset)
paint.color = track
canvas.drawArc(oval, 0f, 360f, false, paint)
paint.color = Color.WHITE
val sweep = 360f * progress.coerceIn(0f, 1f)
if (sweep > 0) canvas.drawArc(oval, -90f, sweep, false, paint)
return bitmap
}
/** A trip's days as segments, filled up to [index]: white, the widget tints it. Track at a third. */
fun segments(index: Int, total: Int, width: Int = 600, height: Int = 10): Bitmap {
val count = total.coerceIn(1, 31)
val filled = if (total > 31) (index * 31f / total).toInt().coerceAtLeast(1) else index
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val gap = if (count > 14) 4f else 8f
val w = (width - gap * (count - 1)) / count
for (i in 0 until count) {
paint.color = if (i < filled) Color.WHITE else Color.argb(85, 255, 255, 255)
val left = i * (w + gap)
canvas.drawRoundRect(android.graphics.RectF(left, 0f, left + w, height.toFloat()), height / 2f, height / 2f, paint)
}
return bitmap
}
}
@@ -1,176 +0,0 @@
package com.liketrek.trek.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AutoAwesome
import androidx.compose.material.icons.rounded.Luggage
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.lifecycleScope
import com.liketrek.trek.R
import com.liketrek.trek.ui.TrekMaterialTheme
import kotlinx.coroutines.launch
/**
* "Choose trip" for a placed widget, the counterpart of the iOS widget's Edit
* Widget (TripIntent.swift): Automatic follows the dashboard's trip, or one of
* the trips the app listed (key "trips"). Shown when the widget is placed
* (optional, it starts on Automatic) and again from the widget's long press.
*/
class WidgetConfigActivity : ComponentActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
appWidgetId = intent?.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// Backing out keeps the widget on what it had (Automatic for a new one).
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
val trips = WidgetStore.trips(this)
val current = WidgetStore.tripFor(this, appWidgetId)
setContent {
TrekMaterialTheme {
TripPicker(trips, current, onPick = ::pick)
}
}
}
private fun pick(tripId: String) {
WidgetStore.setTripFor(this, appWidgetId, tripId)
lifecycleScope.launch {
val manager = GlanceAppWidgetManager(this@WidgetConfigActivity)
val glanceId = runCatching { manager.getGlanceIdBy(appWidgetId) }.getOrNull()
// Only the widget this id belongs to: Glance keys a session by the id alone,
// so another class updating it would draw its own layout there.
val widget = TrekWidgetUpdater.widgetFor(this@WidgetConfigActivity, appWidgetId)
if (glanceId != null && widget != null) runCatching { widget.update(this@WidgetConfigActivity, glanceId) }
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
finish()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TripPicker(trips: List<TripRef>, current: String, onPick: (String) -> Unit) {
var selected by remember { mutableStateOf(current) }
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(stringResource(R.string.widget_intent_title)) },
scrollBehavior = scroll,
)
},
) { padding ->
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() + 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text(
stringResource(R.string.widget_intent_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp),
)
}
item {
PickRow(
title = stringResource(R.string.widget_intent_auto),
subtitle = stringResource(R.string.widget_intent_auto_hint),
leading = { Icon(Icons.Rounded.AutoAwesome, null, tint = MaterialTheme.colorScheme.primary) },
checked = selected == WidgetStore.AUTO,
) {
selected = WidgetStore.AUTO
onPick(WidgetStore.AUTO)
}
}
items(trips, key = { it.id }) { trip ->
PickRow(
title = trip.title,
subtitle = RelativeDayRange(trip),
leading = { Icon(Icons.Rounded.Luggage, null, tint = MaterialTheme.colorScheme.secondary) },
checked = selected == trip.id.toString(),
) {
selected = trip.id.toString()
onPick(trip.id.toString())
}
}
}
}
}
@Composable
private fun RelativeDayRange(trip: TripRef): String? {
val context = androidx.compose.ui.platform.LocalContext.current
return RelativeDay.range(context, trip.start, trip.end)
}
@Composable
private fun PickRow(title: String, subtitle: String?, leading: @Composable () -> Unit, checked: Boolean, onClick: () -> Unit) {
Surface(
shape = RoundedCornerShape(20.dp),
color = if (checked) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(40.dp)) {
Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { leading() }
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (subtitle != null) {
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
RadioButton(selected = checked, onClick = onClick)
}
}
}
@@ -1,89 +0,0 @@
package com.liketrek.trek.widget
import android.content.Context
import com.liketrek.trek.R
import java.time.LocalDate
import org.json.JSONObject
/**
* The sample trip the widget picker shows before the app ever wrote a snapshot,
* and what the screenshot tests draw: the same trip to Kyoto as the iOS gallery
* (WidgetSnapshot.sample in TrekFamily.swift).
*/
object WidgetSamples {
fun emptyText(context: Context): String = context.getString(R.string.widget_empty)
fun snapshot(running: Boolean, now: LocalDate = LocalDate.now()): WidgetSnapshot = WidgetSnapshot.fromJson(JSONObject(json(running, now)))
fun json(running: Boolean, now: LocalDate = LocalDate.now()): String {
fun iso(offset: Long) = now.plusDays(offset).toString()
val start = if (running) -2L else 12L
val today = if (running) {
"""
"today": {"dayLabel": "Day 3 of 9", "index": 3, "total": 9, "stops": [
{"time": "08:30", "name": "Fushimi Inari Taisha", "minutes": 510, "color": "#D6273B", "symbol": "building.columns.fill"},
{"time": "11:00", "name": "Nishiki Market", "minutes": 660, "color": "#E8A13A", "symbol": "storefront.fill"},
{"time": "13:30", "name": "Kiyomizu-dera", "minutes": 810, "color": "#2FA37A", "symbol": "leaf.fill"},
{"time": "16:00", "name": "Gion walk", "minutes": 960, "color": "#4A7DDB", "symbol": "figure.walk"},
{"time": "19:30", "name": "Dinner at Pontocho", "minutes": 1170, "color": "#8E6CD9", "symbol": "fork.knife"}
]},
"""
} else {
""
}
return """
{
"trip": {"title": "Autumn in Kyoto", "start": "${iso(start)}", "end": "${iso(start + 8)}", "path": "/trips/1",
"place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3},
$today
"weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10},
"bookings": [
{"title": "LH 716", "kind": "flight", "date": "${iso(if (running) 6 else 12)}", "time": "13:05", "detail": "FRA → HND", "confirmed": true, "path": "/trips/1"},
{"title": "Shinkansen Nozomi 21", "kind": "train", "date": "${iso(if (running) 0 else 13)}", "time": "09:12", "detail": "Tokyo → Kyoto", "confirmed": true, "path": "/trips/1"},
{"title": "Hotel Kanra Kyoto", "kind": "hotel", "date": "${iso(if (running) 0 else 13)}", "time": "15:00", "detail": "6 nights", "confirmed": true, "path": "/trips/1"},
{"title": "Kaiseki at Kikunoi", "kind": "restaurant", "date": "${iso(if (running) 1 else 15)}", "time": "19:00", "detail": "Higashiyama", "confirmed": false, "path": "/trips/1"},
{"title": "Tea ceremony", "kind": "event", "date": "${iso(if (running) 2 else 16)}", "time": "10:30", "detail": "Camellia Flower", "confirmed": true, "path": "/trips/1"}
],
"todos": {"open": 4, "total": 11, "path": "/trips/1/todos", "items": [
{"name": "Order JR Pass", "due": "${iso(if (running) 0 else 3)}", "priority": 3},
{"name": "Book Ghibli Museum tickets", "due": "${iso(if (running) 1 else 5)}", "priority": 2},
{"name": "Get yen in cash", "priority": 1},
{"name": "Download offline maps", "priority": 0}
]},
"packing": {"packed": 23, "total": 31, "path": "/trips/1/packing",
"bags": [
{"name": "Suitcase", "color": "#4A7DDB", "grams": 14200, "limit": 23000, "packed": 15, "total": 19},
{"name": "Daypack", "color": "#2FA37A", "grams": 4100, "limit": 8000, "packed": 8, "total": 12}
],
"missing": [
{"name": "Rain jacket", "category": "Clothes", "color": "#4A7DDB"},
{"name": "Power bank", "category": "Tech", "color": "#2FA37A"},
{"name": "Passport", "category": "Documents", "color": "#2FA37A"},
{"name": "Sandals", "category": "Shoes", "color": "#4A7DDB"}
]},
"atlas": {"count": 23, "trips": 41, "days": 318,
"codes": ["DE","AT","CH","FR","IT","ES","PT","NL","BE","DK","NO","SE","GB","IE","GR","HR","JP","TH","VN","US","CA","MA","AU"],
"recent": [{"code": "JP", "name": "Japan"}, {"code": "NO", "name": "Norway"}, {"code": "PT", "name": "Portugal"}, {"code": "MA", "name": "Morocco"}],
"continents": [{"name": "Europe", "count": 16}, {"name": "Asia", "count": 3}, {"name": "North America", "count": 2}, {"name": "Africa", "count": 1}, {"name": "Oceania", "count": 1}]},
"collab": {"path": "/trips/1?tab=collab",
"members": [{"name": "Maurice", "initials": "MA"}, {"name": "Julien", "initials": "JU"}, {"name": "Elmo", "initials": "EL"}, {"name": "Sara", "initials": "SA"}],
"messages": [
{"author": "Julien", "initials": "JU", "text": "Got the JR Pass vouchers, all four of us", "mine": false},
{"author": "Sara", "initials": "SA", "text": "Can we push dinner to 20:00? Train is late", "mine": false},
{"author": "Maurice", "initials": "MA", "text": "Sure, I moved the table at Pontocho", "mine": true}
],
"poll": {"question": "Day trip on Saturday?", "voters": 4, "options": [{"label": "Nara", "votes": 3}, {"label": "Uji", "votes": 1}, {"label": "Kurama", "votes": 0}]}},
"labels": {
"nextTrip": "Next trip", "today": "Today", "noTrip": "No trip coming up. Plan the next one in TREK.",
"inDays": "in {n} days", "tomorrow": "Tomorrow", "nothingPlanned": "Nothing planned today",
"days": "days", "bookings": "Coming up", "noBookings": "No bookings ahead",
"todos": "To-dos", "noTodos": "All done", "packing": "Packing", "packed": "packed",
"confirmed": "Confirmed", "pending": "Pending", "todayShort": "Today", "tomorrowShort": "Tomorrow",
"now": "Now", "places": "places", "travellers": "travellers", "allDone": "Day done",
"missing": "still to pack", "countries": "countries", "world": "of the world", "group": "Travel group",
"poll": "Poll", "votes": "votes", "noMessages": "No messages yet", "departure": "Departure"
}
}
""".trimIndent()
}
}
@@ -1,286 +0,0 @@
package com.liketrek.trek.widget
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
import org.json.JSONArray
import org.json.JSONObject
/**
* What the app last told the widgets: client/src/native/widgets.ts builds it and
* hands it over through TrekShell.setWidgetSnapshot, the same JSON the iOS
* widgets read (client/ios/App/TREKWidgets/WidgetSnapshot.swift). Texts arrive
* already translated, so a widget always speaks the language the app does.
*
* Everything past trip, today and labels is optional: a binary newer than the
* server's UI shows what it gets, and unknown fields are ignored.
*/
data class WidgetSnapshot(
val trip: Trip?,
val today: Today?,
val weather: Weather?,
val bookings: List<Booking>,
val todos: Todos?,
val packing: Packing?,
val atlas: Atlas?,
val collab: Collab?,
val labels: Labels,
) {
data class Trip(
val title: String,
val start: String,
val end: String,
val path: String,
val place: String?,
/** A small JPEG of the cover, base64. */
val cover: String?,
val dayCount: Int?,
val placeCount: Int?,
val memberCount: Int?,
)
data class Stop(
val time: String?,
val name: String,
/** Minutes after midnight. */
val minutes: Int?,
/** #rrggbb */
val color: String?,
/** The SF Symbol name the iOS widget uses; [TrekIcons] maps it. */
val symbol: String?,
)
data class Today(val dayLabel: String, val stops: List<Stop>, val index: Int?, val total: Int?)
data class Weather(
val temp: Int,
val high: Int?,
val low: Int?,
val main: String,
val label: String,
val place: String?,
val rain: Int?,
)
data class Booking(
val title: String,
val kind: String,
val date: String?,
val time: String?,
val detail: String?,
val confirmed: Boolean,
val path: String,
)
data class Todo(val name: String, val due: String?, val priority: Int)
data class Todos(val open: Int, val total: Int, val items: List<Todo>, val path: String)
data class Bag(val name: String, val color: String, val grams: Int, val limit: Int?, val packed: Int, val total: Int)
data class PackItem(val name: String, val category: String?, val color: String?)
data class Packing(val packed: Int, val total: Int, val path: String, val bags: List<Bag>, val missing: List<PackItem>)
data class Country(val code: String, val name: String)
data class Continent(val name: String, val count: Int)
data class Atlas(
val count: Int,
val codes: List<String>,
val recent: List<Country>,
val continents: List<Continent>,
val trips: Int?,
val days: Int?,
)
data class Member(val name: String, val initials: String)
data class Message(val author: String, val initials: String, val text: String, val at: String?, val mine: Boolean)
data class PollOption(val label: String, val votes: Int)
data class Poll(val question: String, val options: List<PollOption>, val voters: Int)
data class Collab(val members: List<Member>, val messages: List<Message>, val poll: Poll?, val path: String)
/** The labels, translated by the app. The optional ones fall back to English. */
data class Labels(private val raw: JSONObject) {
val nextTrip get() = text("nextTrip", "Next trip")
val today get() = text("today", "Today")
val noTrip get() = text("noTrip", "No trip coming up. Plan the next one in TREK.")
val inDays get() = text("inDays", "in {n} days")
val tomorrow get() = text("tomorrow", "Tomorrow")
val nothingPlanned get() = text("nothingPlanned", "Nothing planned today")
val days get() = text("days", "days")
val bookings get() = text("bookings", "Coming up")
val noBookings get() = text("noBookings", "No bookings ahead")
val todos get() = text("todos", "To-dos")
val noTodos get() = text("noTodos", "All done")
val packing get() = text("packing", "Packing")
val packed get() = text("packed", "packed")
val confirmed get() = text("confirmed", "Confirmed")
val pending get() = text("pending", "Pending")
val todayShort get() = text("todayShort", today)
val tomorrowShort get() = text("tomorrowShort", tomorrow)
val now get() = text("now", "Now")
val places get() = text("places", "places")
val travellers get() = text("travellers", "travellers")
val allDone get() = text("allDone", "Day done")
val openTrek get() = text("openTrek", "Open TREK")
val missing get() = text("missing", "still to pack")
val countries get() = text("countries", "countries")
val world get() = text("world", "of the world")
val group get() = text("group", "Travel group")
val poll get() = text("poll", "Poll")
val votes get() = text("votes", "votes")
val noMessages get() = text("noMessages", "No messages yet")
val departure get() = text("departure", "Departure")
private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback }
}
/** Whole days from [now] to the trip's start. */
fun daysUntilStart(now: LocalDate = LocalDate.now()): Long? = trip?.let { day(it.start) }?.let { ChronoUnit.DAYS.between(now, it) }
/** The trip's length in days, from its dates when the app sent no count. */
val tripLength: Int?
get() {
trip?.dayCount?.takeIf { it > 0 }?.let { return it }
val start = trip?.let { day(it.start) } ?: return null
val end = day(trip.end) ?: return null
return ChronoUnit.DAYS.between(start, end).toInt() + 1
}
companion object {
fun parse(json: String): WidgetSnapshot? = try {
fromJson(JSONObject(json))
} catch (e: Exception) {
null
}
fun fromJson(o: JSONObject): WidgetSnapshot = WidgetSnapshot(
trip = o.optJSONObject("trip")?.let {
Trip(
title = it.optString("title"),
start = it.optString("start"),
end = it.optString("end"),
path = it.optString("path", "/"),
place = it.str("place"),
cover = it.str("cover"),
dayCount = it.int("dayCount"),
placeCount = it.int("placeCount"),
memberCount = it.int("memberCount"),
)
},
today = o.optJSONObject("today")?.let { t ->
Today(
dayLabel = t.optString("dayLabel"),
stops = t.optJSONArray("stops").objects().map {
Stop(it.str("time"), it.optString("name"), it.int("minutes"), it.str("color"), it.str("symbol"))
},
index = t.int("index"),
total = t.int("total"),
)
},
weather = o.optJSONObject("weather")?.let {
Weather(it.optInt("temp"), it.int("high"), it.int("low"), it.optString("main"), it.optString("label"), it.str("place"), it.int("rain"))
},
bookings = o.optJSONArray("bookings").objects().map {
Booking(
title = it.optString("title"),
kind = it.optString("kind", "other"),
date = it.str("date"),
time = it.str("time"),
detail = it.str("detail"),
confirmed = it.optBoolean("confirmed"),
path = it.optString("path", "/"),
)
},
todos = o.optJSONObject("todos")?.let { t ->
Todos(
open = t.optInt("open"),
total = t.optInt("total"),
items = t.optJSONArray("items").objects().map { Todo(it.optString("name"), it.str("due"), it.optInt("priority")) },
path = t.optString("path", "/"),
)
},
packing = o.optJSONObject("packing")?.let { p ->
Packing(
packed = p.optInt("packed"),
total = p.optInt("total"),
path = p.optString("path", "/"),
bags = p.optJSONArray("bags").objects().map {
Bag(it.optString("name"), it.optString("color", "#4A7DDB"), it.optInt("grams"), it.int("limit"), it.optInt("packed"), it.optInt("total"))
},
missing = p.optJSONArray("missing").objects().map { PackItem(it.optString("name"), it.str("category"), it.str("color")) },
)
},
atlas = o.optJSONObject("atlas")?.let { a ->
Atlas(
count = a.optInt("count"),
codes = a.optJSONArray("codes").strings(),
recent = a.optJSONArray("recent").objects().map { Country(it.optString("code"), it.optString("name")) },
continents = a.optJSONArray("continents").objects().map { Continent(it.optString("name"), it.optInt("count")) },
trips = a.int("trips"),
days = a.int("days"),
)
},
collab = o.optJSONObject("collab")?.let { c ->
Collab(
members = c.optJSONArray("members").objects().map { Member(it.optString("name"), it.optString("initials")) },
messages = c.optJSONArray("messages").objects().map {
Message(it.optString("author"), it.optString("initials"), it.optString("text"), it.str("at"), it.optBoolean("mine"))
},
poll = c.optJSONObject("poll")?.let { p ->
Poll(p.optString("question"), p.optJSONArray("options").objects().map { PollOption(it.optString("label"), it.optInt("votes")) }, p.optInt("voters"))
},
path = c.optString("path", "/"),
)
},
labels = Labels(o.optJSONObject("labels") ?: JSONObject()),
)
fun day(iso: String?): LocalDate? {
if (iso.isNullOrBlank() || iso.length < 10) return null
return try {
LocalDate.parse(iso.substring(0, 10))
} catch (e: DateTimeParseException) {
null
}
}
/** The app link a tap opens: com.liketrek.trek://navigate<path>, which the UI routes (nativeApp.ts). */
fun link(path: String): String = "com.liketrek.trek://navigate" + if (path.startsWith("/")) path else "/$path"
private fun JSONObject.str(key: String): String? = if (isNull(key)) null else optString(key).ifEmpty { null }
private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
private fun JSONArray?.objects(): List<JSONObject> =
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
private fun JSONArray?.strings(): List<String> =
if (this == null) emptyList() else (0 until length()).mapNotNull { optString(it).ifEmpty { null } }
}
}
/** A trip a widget can be set to, as the app lists them (key "trips"). */
data class TripRef(val id: Long, val title: String, val start: String, val end: String) {
companion object {
fun parseList(json: String?): List<TripRef> {
if (json.isNullOrBlank()) return emptyList()
return try {
val array = JSONArray(json)
(0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
if (!o.has("id")) return@mapNotNull null
TripRef(o.optLong("id"), o.optString("title"), o.optString("start"), o.optString("end"))
}
} catch (e: Exception) {
emptyList()
}
}
}
}
@@ -1,80 +0,0 @@
package com.liketrek.trek.widget
import android.content.Context
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Where the widgets' snapshots live: one JSON file per key in the app's own
* storage. The widgets run in the app's process, so no shared container is
* needed as on iOS; the keys are the same ones the UI writes there
* ("snapshot", "trips", "trip.<id>").
*/
object WidgetStore {
private val KEY = Regex("^(snapshot|trips|trip\\.[0-9]{1,12})$")
private const val PREFS = "trek.widgets"
const val AUTO = "auto"
private val changes = MutableStateFlow(0L)
/**
* Counts every write and every trip picked for a widget. A widget reads its
* data inside its composition, keyed on this, so a write that lands while its
* Glance session still runs is drawn too, not only the first one.
*/
val version: StateFlow<Long> = changes
fun isValidKey(key: String): Boolean = KEY.matches(key)
private fun dir(context: Context): File = File(context.filesDir, "widgets").apply { mkdirs() }
fun write(context: Context, key: String, json: String) {
require(isValidKey(key)) { "key must be snapshot, trips or trip.<id>" }
val target = File(dir(context), "$key.json")
val temp = File(dir(context), "$key.json.tmp")
temp.writeText(json)
if (!temp.renameTo(target)) {
target.delete()
temp.renameTo(target)
}
changes.value++
}
fun read(context: Context, key: String): String? {
if (!isValidKey(key)) return null
val file = File(dir(context), "$key.json")
return if (file.exists()) runCatching { file.readText() }.getOrNull() else null
}
/** The dashboard's snapshot. */
fun load(context: Context): WidgetSnapshot? = read(context, "snapshot")?.let(WidgetSnapshot::parse)
/**
* The snapshot of the trip a widget is set to; the dashboard's for none or
* "auto", and the dashboard's too while the app has not written that trip.
* A trip's snapshot carries no Atlas, so it borrows the dashboard's.
*/
fun load(context: Context, tripId: String?): WidgetSnapshot? {
if (tripId == null || tripId == AUTO) return load(context)
val own = read(context, "trip.$tripId")?.let(WidgetSnapshot::parse) ?: return load(context)
return if (own.atlas == null) own.copy(atlas = load(context)?.atlas) else own
}
fun trips(context: Context): List<TripRef> = TripRef.parseList(read(context, "trips"))
/** The trip picked for one placed widget, "auto" when none was. */
fun tripFor(context: Context, appWidgetId: Int): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("trip.$appWidgetId", AUTO) ?: AUTO
fun setTripFor(context: Context, appWidgetId: Int, tripId: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("trip.$appWidgetId", tripId).commit()
changes.value++
}
fun forget(context: Context, appWidgetIds: IntArray) {
val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
for (id in appWidgetIds) editor.remove("trip.$id")
editor.apply()
}
}
@@ -1,278 +0,0 @@
package com.liketrek.trek.widget
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.text.format.DateFormat
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
import androidx.glance.GlanceComposable
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.material3.ColorProviders
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import com.liketrek.trek.MainActivity
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
/**
* What every TREK widget shares: Material You's colours on Android 12 and later
* (the wallpaper's), TREK's own before that, the system's corner radius, the
* tap that opens a place in the app, and the small pieces (eyebrow, chip, icon
* badge) the widgets are built from.
*/
object TrekWidgetTheme {
/** TREK's palette (client/src/mobile/mobile.css) for systems without dynamic colour. */
val fallback = ColorProviders(
light = lightColorScheme(
primary = Color(0xFF101013),
onPrimary = Color(0xFFF5F5F7),
primaryContainer = Color(0xFFE7E5DF),
onPrimaryContainer = Color(0xFF101013),
secondary = Color(0xFF4A7DDB),
secondaryContainer = Color(0xFFE4EAF7),
onSecondaryContainer = Color(0xFF1B2F57),
tertiary = Color(0xFF2FA37A),
surface = Color(0xFFF7F6F2),
onSurface = Color(0xFF101013),
onSurfaceVariant = Color(0xFF68686F),
surfaceVariant = Color(0xFFECEAE4),
outline = Color(0xFF9A9AA1),
background = Color(0xFFF1EFEA),
error = Color(0xFFD6273B),
),
dark = darkColorScheme(
primary = Color(0xFFF5F5F7),
onPrimary = Color(0xFF101013),
primaryContainer = Color(0xFF26262C),
onPrimaryContainer = Color(0xFFF5F5F7),
secondary = Color(0xFF8AAEF0),
secondaryContainer = Color(0xFF233250),
onSecondaryContainer = Color(0xFFDCE6FA),
tertiary = Color(0xFF5CC79E),
surface = Color(0xFF121216),
onSurface = Color(0xFFF5F5F7),
onSurfaceVariant = Color(0xFF9C9CA3),
surfaceVariant = Color(0xFF1E1E24),
outline = Color(0xFF6E6E76),
background = Color(0xFF0C0C0F),
error = Color(0xFFFF6B7A),
),
)
@Composable
fun Themed(content: @Composable () -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme(content = content) else GlanceTheme(colors = fallback, content = content)
}
}
/** The status colours, the same on every wallpaper so they keep their meaning. */
object TrekStatus {
val confirmed = ColorProvider(Color(0xFF2FA37A))
val pending = ColorProvider(Color(0xFFE8A13A))
val danger = ColorProvider(Color(0xFFD6273B))
val white = ColorProvider(Color.White)
val whiteMuted = ColorProvider(Color(0xCCFFFFFF))
val whiteFaint = ColorProvider(Color(0x99FFFFFF))
val scrim = ColorProvider(Color(0x66000000))
val glass = ColorProvider(Color(0x33000000))
}
/** Opens [path] of the user's server in the app (nativeApp.ts routes com.liketrek.trek://navigate). */
fun openPath(context: Context, path: String): Action = actionStartActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(WidgetSnapshot.link(path)), context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
/** The ground of a widget: the system's widget shape and the theme's widget background. */
@Composable
fun WidgetFrame(
onClick: Action?,
background: ColorProvider = GlanceTheme.colors.widgetBackground,
padding: Dp = 14.dp,
content: @Composable () -> Unit,
) {
var modifier = GlanceModifier.fillMaxSize().appWidgetBackground().background(background).padding(padding)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) modifier = modifier.cornerRadius(android.R.dimen.system_app_widget_background_radius)
if (onClick != null) modifier = modifier.clickable(onClick)
Box(modifier = modifier) { content() }
}
/** A small, bold, upper-case line above a widget's content. */
@Composable
@GlanceComposable
fun Eyebrow(text: String, color: ColorProvider = GlanceTheme.colors.onSurfaceVariant, modifier: GlanceModifier = GlanceModifier) {
Text(
text = text.uppercase(Locale.getDefault()),
style = TextStyle(color = color, fontSize = 11.sp, fontWeight = FontWeight.Bold),
maxLines = 1,
modifier = modifier,
)
}
@Composable
fun Line(
text: String,
size: TextUnit = 13.sp,
color: ColorProvider = GlanceTheme.colors.onSurface,
weight: FontWeight = FontWeight.Normal,
maxLines: Int = 1,
modifier: GlanceModifier = GlanceModifier,
) {
Text(text = text, style = TextStyle(color = color, fontSize = size, fontWeight = weight), maxLines = maxLines, modifier = modifier)
}
/** An icon in a tinted circle: a booking's kind, a stop's category. */
@Composable
fun IconBadge(symbol: String?, tint: ColorProvider, background: ColorProvider, size: Dp = 28.dp) {
Box(
modifier = GlanceModifier.size(size).background(background).cornerRadius(size / 2),
contentAlignment = Alignment.Center,
) {
Image(
provider = TrekIcons.provider(symbol),
contentDescription = null,
colorFilter = ColorFilter.tint(tint),
modifier = GlanceModifier.size(size * 0.58f),
)
}
}
@Composable
fun Icon(vector: ImageVector, tint: ColorProvider, size: Dp = 14.dp) {
Image(provider = TrekIcons.provider(vector), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(size))
}
@Composable
fun SymbolIcon(symbol: String, tint: ColorProvider, size: Dp = 14.dp) {
Image(provider = TrekIcons.provider(symbol), contentDescription = null, colorFilter = ColorFilter.tint(tint), modifier = GlanceModifier.size(size))
}
/** A rounded pill with a short text: a date range, a weather reading, a status. */
@Composable
fun Chip(
text: String,
background: ColorProvider,
color: ColorProvider,
leading: ImageProvider? = null,
leadingTint: ColorProvider? = null,
) {
Row(
modifier = GlanceModifier.background(background).cornerRadius(100.dp).padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) {
Image(
provider = leading,
contentDescription = null,
colorFilter = leadingTint?.let { ColorFilter.tint(it) },
modifier = GlanceModifier.size(12.dp),
)
Spacer(GlanceModifier.width(4.dp))
}
Text(text = text, style = TextStyle(color = color, fontSize = 11.sp, fontWeight = FontWeight.Medium), maxLines = 1)
}
}
@Composable
fun WeatherChip(weather: WidgetSnapshot.Weather, onPhoto: Boolean) {
Chip(
text = "${weather.temp}°",
background = if (onPhoto) TrekStatus.glass else GlanceTheme.colors.secondaryContainer,
color = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer,
leading = TrekIcons.provider(TrekIcons.weather(weather.main)),
leadingTint = if (onPhoto) TrekStatus.white else GlanceTheme.colors.onSecondaryContainer,
)
}
/** A horizontal bar of [value] (0..1): Glance's progress indicator, rounded. */
@Composable
fun Bar(value: Float, color: ColorProvider, track: ColorProvider, height: Dp = 6.dp, modifier: GlanceModifier = GlanceModifier.fillMaxWidth()) {
androidx.glance.appwidget.LinearProgressIndicator(
progress = value.coerceIn(0f, 1f),
color = color,
backgroundColor = track,
modifier = modifier.height(height).cornerRadius(height / 2),
)
}
/** The day segments of a trip: one bar per day, filled up to today. Drawn as one image (a Row takes ten children at most). */
@Composable
fun DaySegments(index: Int, total: Int, color: ColorProvider) {
Image(
provider = ImageProvider(WidgetArt.segments(index, total)),
contentDescription = "$index / $total",
colorFilter = ColorFilter.tint(color),
contentScale = androidx.glance.layout.ContentScale.FillBounds,
modifier = GlanceModifier.fillMaxWidth().height(4.dp),
)
}
/** Relative days and ranges in the device's language. */
object RelativeDay {
fun label(iso: String?, labels: WidgetSnapshot.Labels, today: LocalDate = LocalDate.now(), locale: Locale = Locale.getDefault()): String? {
val date = WidgetSnapshot.day(iso) ?: return null
val days = ChronoUnit.DAYS.between(today, date)
if (days == 0L) return labels.todayShort
if (days == 1L) return labels.tomorrowShort
val skeleton = if (days in 2..6) "EEE" else "EEEdMMM"
return DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale).format(date)
}
/** "1-9 Oct" in the device's own way of writing a range. */
fun range(context: Context, start: String, end: String): String? {
val from = WidgetSnapshot.day(start) ?: return null
val to = WidgetSnapshot.day(end) ?: return null
val zone = java.time.ZoneId.systemDefault()
val startMs = from.atStartOfDay(zone).toInstant().toEpochMilli()
// formatDateRange's end is exclusive: noon of the last day keeps it in the range.
val endMs = to.atTime(12, 0).atZone(zone).toInstant().toEpochMilli()
val flags = android.text.format.DateUtils.FORMAT_SHOW_DATE or android.text.format.DateUtils.FORMAT_ABBREV_MONTH or
android.text.format.DateUtils.FORMAT_NO_YEAR
return android.text.format.DateUtils.formatDateRange(context, startMs, endMs, flags)
}
}
/** Minutes after midnight, now. */
fun nowMinutes(): Int = java.time.LocalTime.now().let { it.hour * 60 + it.minute }
/** "#rrggbb" as a colour, null for anything else. */
fun cssColor(css: String?): ColorProvider? {
val hex = css?.removePrefix("#") ?: return null
if (hex.length != 6) return null
val value = hex.toLongOrNull(16) ?: return null
return ColorProvider(Color(0xFF000000 or value))
}
@@ -1,53 +0,0 @@
package com.liketrek.trek.widget
/**
* The world as 76 by 34 dots from 80 degrees north to 58 south, each the ISO
* code of the country under it, ".." for sea. The same grid as the iOS widget
* (client/ios/App/TREKWidgets/WorldGrid.swift, generated from
* server/assets/atlas/admin0.geojson.gz); copy it over when that one changes.
*/
object WorldGrid {
const val COLUMNS = 76
val ROWS: List<String> = listOf(
"........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................",
"......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............",
"......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU",
"RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
"......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
"........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......",
"......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........",
"......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............",
"........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................",
"......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............",
"........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................",
"..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................",
"..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................",
"............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................",
"..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................",
"................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................",
"....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................",
"........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................",
"..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................",
"..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................",
"..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........",
"..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........",
"..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............",
"............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............",
"..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......",
"..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............",
"..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
"..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
"............................................CLARARARUY..............................ZA......................................AU........AUAUAU............",
"............................................CLARARAR....................................................................................AU............NZ",
"............................................CLARAR......................................................................................AU........NZNZ..",
"............................................CLAR........................................................................................................",
"............................................CLAR........................................................................................................",
"..............................................CL........................................................................................................",
)
/** The country code of a dot, null for sea. */
fun code(row: Int, column: Int): String? {
val code = ROWS[row].substring(column * 2, column * 2 + 2)
return if (code == "..") null else code
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status bar icon for notifications: a single-colour map pin, as Android
tints it and shows only its shape. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The shade over a trip's cover in its widget: darker towards the text at the bottom. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="270"
android:startColor="#59000000"
android:centerColor="#0D000000"
android:endColor="#A6000000" />
</shape>
@@ -1 +0,0 @@
unqualifiedResLocale=en-US
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">رحلة</string>
<string name="widget_trip_description">عدّ تنازلي لرحلتك القادمة، ثم اليوم وما يليه أثناء السفر.</string>
<string name="widget_today_name">اليوم</string>
<string name="widget_today_description">خطة اليوم كخط زمني، مع الطقس والمحطة التالية.</string>
<string name="widget_bookings_name">الحجوزات</string>
<string name="widget_bookings_description">رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.</string>
<string name="widget_todos_name">المهام</string>
<string name="widget_todos_description">ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.</string>
<string name="widget_packing_name">قائمة الأمتعة</string>
<string name="widget_packing_description">مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا.</string>
<string name="widget_atlas_name">خريطة العالم</string>
<string name="widget_atlas_description">الدول التي زرتها مضيئة على الخريطة.</string>
<string name="widget_collab_name">مجموعة السفر</string>
<string name="widget_collab_description">من يرافقك وأحدث الرسائل والتصويت المفتوح.</string>
<string name="widget_intent_title">اختر الرحلة</string>
<string name="widget_intent_description">الرحلة التي تعرضها هذه الأداة.</string>
<string name="widget_intent_trip">الرحلة</string>
<string name="widget_intent_auto">تلقائي</string>
<string name="widget_intent_auto_hint">نفس رحلة لوحة التحكم</string>
<string name="share_title">إضافة إلى TREK</string>
<string name="share_cancel">إلغاء</string>
<string name="share_close">إغلاق</string>
<string name="share_trip">الرحلة</string>
<string name="share_no_trips">افتح TREK مرة واحدة وستظهر رحلاتك هنا.</string>
<string name="share_hint">يقرأ TREK الحجز ويضيفه إلى الرحلة.</string>
<string name="share_later">الاختيار لاحقًا</string>
<string name="share_add">إضافة</string>
<string name="share_add_to">إضافة إلى %1$s</string>
<string name="share_added">تمت الإضافة</string>
<string name="share_reading">جارٍ القراءة…</string>
<string name="share_too_large">هذا الملف أكبر من أن يُستورد.</string>
<string name="share_unsupported">لا يستطيع TREK استيراد هذا.</string>
<string name="share_failed">تعذّر تمرير هذا إلى TREK. حاول مرة أخرى.</string>
<string name="share_now">الآن</string>
<string name="share_past">انتهت</string>
<string name="share_tomorrow">غدًا</string>
<string name="share_in_days">بعد %1$d يومًا</string>
<string name="share_kind_pass">بطاقة Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">بريد</string>
<string name="share_kind_page">صفحة ويب</string>
<string name="share_kind_text">نص</string>
<string name="share_kind_image">صورة</string>
<string name="share_kind_file">ملف</string>
<string name="widget_empty">افتح TREK مرة واحدة وستظهر رحلاتك هنا.</string>
<string name="push_channel_chat">دردشة الرحلة</string>
<string name="push_channel_trips">تحديثات الرحلة</string>
<string name="push_channel_invites">الدعوات والمشاركات</string>
<string name="push_channel_name">عام</string>
<string name="push_channel_tripday">يوم السفر</string>
<string name="push_fallback_body">إشعار جديد</string>
<string name="push_channel_tripday_description">يوم الرحلة الجارية ومحطتها التالية.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Səyahət</string>
<string name="widget_trip_description">Növbəti səyahətə qədər günləri sayır, yolda isə günü və sonrakını göstərir.</string>
<string name="widget_today_name">Bu gün</string>
<string name="widget_today_description">Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.</string>
<string name="widget_bookings_name">Bronlar</string>
<string name="widget_bookings_description">Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.</string>
<string name="widget_todos_name">Tapşırıqlar</string>
<string name="widget_todos_description">Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.</string>
<string name="widget_packing_name">Yük siyahısı</string>
<string name="widget_packing_description">Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı.</string>
<string name="widget_atlas_name">Dünya xəritəsi</string>
<string name="widget_atlas_description">Olduğun ölkələr xəritədə parlayır.</string>
<string name="widget_collab_name">Səyahət qrupu</string>
<string name="widget_collab_description">Kim gəlir, son mesajlar və açıq səsvermə.</string>
<string name="widget_intent_title">Səyahəti seç</string>
<string name="widget_intent_description">Bu vidcetin göstərdiyi səyahət.</string>
<string name="widget_intent_trip">Səyahət</string>
<string name="widget_intent_auto">Avtomatik</string>
<string name="widget_intent_auto_hint">İdarə panelindəki səyahət</string>
<string name="share_title">TREK-ə əlavə et</string>
<string name="share_cancel">Ləğv et</string>
<string name="share_close">Bağla</string>
<string name="share_trip">Səyahət</string>
<string name="share_no_trips">TREK-i bir dəfə açın, səyahətləriniz burada görünəcək.</string>
<string name="share_hint">TREK rezervasiyanı oxuyur və səyahətə əlavə edir.</string>
<string name="share_later">Sonra seç</string>
<string name="share_add">Əlavə et</string>
<string name="share_add_to">%1$s səyahətinə əlavə et</string>
<string name="share_added">Əlavə edildi</string>
<string name="share_reading">Oxunur…</string>
<string name="share_too_large">Bu fayl idxal üçün çox böyükdür.</string>
<string name="share_unsupported">TREK bunu idxal edə bilmir.</string>
<string name="share_failed">Bunu TREK-ə ötürmək alınmadı. Yenidən cəhd edin.</string>
<string name="share_now">İndi</string>
<string name="share_past">Keçib</string>
<string name="share_tomorrow">Sabah</string>
<string name="share_in_days">%1$d gün sonra</string>
<string name="share_kind_pass">Wallet kartı</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-poçt</string>
<string name="share_kind_page">Veb səhifə</string>
<string name="share_kind_text">Mətn</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Fayl</string>
<string name="widget_empty">TREK-i bir dəfə açın, səyahətləriniz burada görünəcək.</string>
<string name="push_channel_chat">Səyahət söhbəti</string>
<string name="push_channel_trips">Səyahət yenilikləri</string>
<string name="push_channel_invites">Dəvətlər və paylaşımlar</string>
<string name="push_channel_name">Ümumi</string>
<string name="push_channel_tripday">Səyahət günü</string>
<string name="push_fallback_body">Yeni bildiriş</string>
<string name="push_channel_tripday_description">Davam edən səyahətin günü və növbəti dayanacağı.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">旅行</string>
<string name="widget_trip_description">倒数到下一次旅行,旅途中显示当天行程和接下来的安排。</string>
<string name="widget_today_name">今天</string>
<string name="widget_today_description">以时间线显示今天的计划,附带天气和下一站。</string>
<string name="widget_bookings_name">预订</string>
<string name="widget_bookings_description">你接下来的航班、火车、酒店和餐厅,以及时间。</string>
<string name="widget_todos_name">待办</string>
<string name="widget_todos_description">旅行还剩哪些事没做,以及行李收拾到哪一步。</string>
<string name="widget_packing_name">行李</string>
<string name="widget_packing_description">包装得多满、重量多少,以及还缺什么。</string>
<string name="widget_atlas_name">世界地图</string>
<string name="widget_atlas_description">你去过的国家在地图上亮起。</string>
<string name="widget_collab_name">旅行小组</string>
<string name="widget_collab_description">谁一起去、最新消息和进行中的投票。</string>
<string name="widget_intent_title">选择旅行</string>
<string name="widget_intent_description">此小组件显示的旅行。</string>
<string name="widget_intent_trip">旅行</string>
<string name="widget_intent_auto">自动</string>
<string name="widget_intent_auto_hint">与仪表板相同的旅行</string>
<string name="share_title">添加到 TREK</string>
<string name="share_cancel">取消</string>
<string name="share_close">关闭</string>
<string name="share_trip">行程</string>
<string name="share_no_trips">打开一次 TREK,你的行程就会显示在这里。</string>
<string name="share_hint">TREK 会读取预订并添加到行程中。</string>
<string name="share_later">稍后选择</string>
<string name="share_add">添加</string>
<string name="share_add_to">添加到 %1$s</string>
<string name="share_added">已添加</string>
<string name="share_reading">正在读取…</string>
<string name="share_too_large">此文件太大,无法导入。</string>
<string name="share_unsupported">TREK 无法导入此内容。</string>
<string name="share_failed">无法传给 TREK。请重试。</string>
<string name="share_now">进行中</string>
<string name="share_past">已结束</string>
<string name="share_tomorrow">明天</string>
<string name="share_in_days">%1$d 天后</string>
<string name="share_kind_pass">Wallet 卡券</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">邮件</string>
<string name="share_kind_page">网页</string>
<string name="share_kind_text">文本</string>
<string name="share_kind_image">照片</string>
<string name="share_kind_file">文件</string>
<string name="widget_empty">打开一次 TREK,你的行程就会显示在这里。</string>
<string name="push_channel_chat">行程聊天</string>
<string name="push_channel_trips">行程动态</string>
<string name="push_channel_invites">邀请与共享</string>
<string name="push_channel_name">常规</string>
<string name="push_channel_tripday">旅行日</string>
<string name="push_fallback_body">新通知</string>
<string name="push_channel_tripday_description">进行中行程的当天安排和下一站。</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">旅行</string>
<string name="widget_trip_description">倒數到下一次旅行,旅途中顯示當天行程與接下來的安排。</string>
<string name="widget_today_name">今天</string>
<string name="widget_today_description">以時間軸顯示今天的計畫,附上天氣與下一站。</string>
<string name="widget_bookings_name">預訂</string>
<string name="widget_bookings_description">你接下來的航班、火車、飯店與餐廳,以及時間。</string>
<string name="widget_todos_name">待辦</string>
<string name="widget_todos_description">旅行還剩哪些事沒做,以及行李整理到哪一步。</string>
<string name="widget_packing_name">行李</string>
<string name="widget_packing_description">包包裝得多滿、重量多少,以及還缺什麼。</string>
<string name="widget_atlas_name">世界地圖</string>
<string name="widget_atlas_description">你去過的國家在地圖上亮起。</string>
<string name="widget_collab_name">旅行小組</string>
<string name="widget_collab_description">誰一起去、最新訊息與進行中的投票。</string>
<string name="widget_intent_title">選擇旅行</string>
<string name="widget_intent_description">此小工具顯示的旅行。</string>
<string name="widget_intent_trip">旅行</string>
<string name="widget_intent_auto">自動</string>
<string name="widget_intent_auto_hint">與儀表板相同的旅行</string>
<string name="share_title">加入 TREK</string>
<string name="share_cancel">取消</string>
<string name="share_close">關閉</string>
<string name="share_trip">行程</string>
<string name="share_no_trips">開啟一次 TREK,你的行程就會顯示在這裡。</string>
<string name="share_hint">TREK 會讀取訂位並加入行程。</string>
<string name="share_later">稍後選擇</string>
<string name="share_add">加入</string>
<string name="share_add_to">加入 %1$s</string>
<string name="share_added">已加入</string>
<string name="share_reading">正在讀取…</string>
<string name="share_too_large">此檔案太大,無法匯入。</string>
<string name="share_unsupported">TREK 無法匯入此內容。</string>
<string name="share_failed">無法傳給 TREK。請重試。</string>
<string name="share_now">進行中</string>
<string name="share_past">已結束</string>
<string name="share_tomorrow">明天</string>
<string name="share_in_days">%1$d 天後</string>
<string name="share_kind_pass">Wallet 票卡</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">郵件</string>
<string name="share_kind_page">網頁</string>
<string name="share_kind_text">文字</string>
<string name="share_kind_image">照片</string>
<string name="share_kind_file">檔案</string>
<string name="widget_empty">開啟一次 TREK,你的行程就會顯示在這裡。</string>
<string name="push_channel_chat">行程聊天</string>
<string name="push_channel_trips">行程動態</string>
<string name="push_channel_invites">邀請與共享</string>
<string name="push_channel_name">一般</string>
<string name="push_channel_tripday">旅行日</string>
<string name="push_fallback_body">新通知</string>
<string name="push_channel_tripday_description">進行中行程的當天安排與下一站。</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Viatge</string>
<string name="widget_trip_description">Compta els dies fins al proper viatge i, durant el viatge, mostra el dia i el que ve després.</string>
<string name="widget_today_name">Avui</string>
<string name="widget_today_description">El pla del dia com a línia de temps, amb el temps i la propera parada.</string>
<string name="widget_bookings_name">Reserves</string>
<string name="widget_bookings_description">Els teus propers vols, trens, hotels i taules, i quan toquen.</string>
<string name="widget_todos_name">Tasques</string>
<string name="widget_todos_description">El que encara queda per al viatge i com va l\'equipatge.</string>
<string name="widget_packing_name">Equipatge</string>
<string name="widget_packing_description">Com de plenes són les maletes, el pes i el que encara falta.</string>
<string name="widget_atlas_name">Mapa del món</string>
<string name="widget_atlas_description">Els països on has estat, brillant al mapa.</string>
<string name="widget_collab_name">Grup de viatge</string>
<string name="widget_collab_description">Qui ve, els darrers missatges i la votació oberta.</string>
<string name="widget_intent_title">Tria el viatge</string>
<string name="widget_intent_description">El viatge que mostra aquest giny.</string>
<string name="widget_intent_trip">Viatge</string>
<string name="widget_intent_auto">Automàtic</string>
<string name="widget_intent_auto_hint">El mateix viatge que el tauler</string>
<string name="share_title">Afegeix a TREK</string>
<string name="share_cancel">Cancel·la</string>
<string name="share_close">Tanca</string>
<string name="share_trip">Viatge</string>
<string name="share_no_trips">Obre TREK una vegada i aquí apareixeran els teus viatges.</string>
<string name="share_hint">TREK llegeix la reserva i l’afegeix al viatge.</string>
<string name="share_later">Tria més tard</string>
<string name="share_add">Afegeix</string>
<string name="share_add_to">Afegeix a %1$s</string>
<string name="share_added">Afegit</string>
<string name="share_reading">Llegint…</string>
<string name="share_too_large">Aquest fitxer és massa gran per importar-lo.</string>
<string name="share_unsupported">TREK no pot importar això.</string>
<string name="share_failed">No s’ha pogut passar a TREK. Torna-ho a provar.</string>
<string name="share_now">Ara</string>
<string name="share_past">Passat</string>
<string name="share_tomorrow">Demà</string>
<string name="share_in_days">D’aquí a %1$d dies</string>
<string name="share_kind_pass">Passi de Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Correu</string>
<string name="share_kind_page">Pàgina web</string>
<string name="share_kind_text">Text</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Fitxer</string>
<string name="widget_empty">Obre TREK una vegada i aquí apareixeran els teus viatges.</string>
<string name="push_channel_chat">Xat del viatge</string>
<string name="push_channel_trips">Novetats del viatge</string>
<string name="push_channel_invites">Invitacions i comparticions</string>
<string name="push_channel_name">General</string>
<string name="push_channel_tripday">Dia de viatge</string>
<string name="push_fallback_body">Notificació nova</string>
<string name="push_channel_tripday_description">El dia d\'un viatge en curs i la seva propera parada.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Cesta</string>
<string name="widget_trip_description">Odpočítává dny do další cesty a během ní ukazuje den a co přijde dál.</string>
<string name="widget_today_name">Dnes</string>
<string name="widget_today_description">Plán dne jako časová osa, s počasím a další zastávkou.</string>
<string name="widget_bookings_name">Rezervace</string>
<string name="widget_bookings_description">Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.</string>
<string name="widget_todos_name">Úkoly</string>
<string name="widget_todos_description">Co ještě zbývá k cestě a jak daleko je balení.</string>
<string name="widget_packing_name">Balení</string>
<string name="widget_packing_description">Jak plná jsou zavazadla, jejich váha a co ještě chybí.</string>
<string name="widget_atlas_name">Mapa světa</string>
<string name="widget_atlas_description">Země, kde jsi byl, zářící na mapě.</string>
<string name="widget_collab_name">Cestovní skupina</string>
<string name="widget_collab_description">Kdo jede s tebou, nejnovější zprávy a otevřené hlasování.</string>
<string name="widget_intent_title">Vybrat cestu</string>
<string name="widget_intent_description">Cesta, kterou tento widget zobrazuje.</string>
<string name="widget_intent_trip">Cesta</string>
<string name="widget_intent_auto">Automaticky</string>
<string name="widget_intent_auto_hint">Stejná cesta jako na nástěnce</string>
<string name="share_title">Přidat do TREK</string>
<string name="share_cancel">Zrušit</string>
<string name="share_close">Zavřít</string>
<string name="share_trip">Cesta</string>
<string name="share_no_trips">Jednou otevřete TREK a vaše cesty se zobrazí zde.</string>
<string name="share_hint">TREK přečte rezervaci a přidá ji k cestě.</string>
<string name="share_later">Vybrat později</string>
<string name="share_add">Přidat</string>
<string name="share_add_to">Přidat do %1$s</string>
<string name="share_added">Přidáno</string>
<string name="share_reading">Načítání…</string>
<string name="share_too_large">Tento soubor je pro import příliš velký.</string>
<string name="share_unsupported">Tohle TREK neumí importovat.</string>
<string name="share_failed">Nepodařilo se to předat do TREK. Zkuste to znovu.</string>
<string name="share_now">Teď</string>
<string name="share_past">Proběhla</string>
<string name="share_tomorrow">Zítra</string>
<string name="share_in_days">Za %1$d dní</string>
<string name="share_kind_pass">Karta Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Webová stránka</string>
<string name="share_kind_text">Text</string>
<string name="share_kind_image">Fotka</string>
<string name="share_kind_file">Soubor</string>
<string name="widget_empty">Jednou otevřete TREK a vaše cesty se zobrazí zde.</string>
<string name="push_channel_chat">Chat výletu</string>
<string name="push_channel_trips">Novinky z výletu</string>
<string name="push_channel_invites">Pozvánky a sdílení</string>
<string name="push_channel_name">Obecné</string>
<string name="push_channel_tripday">Cestovní den</string>
<string name="push_fallback_body">Nové oznámení</string>
<string name="push_channel_tripday_description">Den probíhajícího výletu a jeho další zastávka.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Reise</string>
<string name="widget_trip_description">Zählt die Tage bis zur nächsten Reise und zeigt unterwegs den Tag und was als Nächstes kommt.</string>
<string name="widget_today_name">Heute</string>
<string name="widget_today_description">Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.</string>
<string name="widget_bookings_name">Buchungen</string>
<string name="widget_bookings_description">Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.</string>
<string name="widget_todos_name">To-dos</string>
<string name="widget_todos_description">Was für die Reise noch offen ist und wie weit das Packen ist.</string>
<string name="widget_packing_name">Packliste</string>
<string name="widget_packing_description">Wie voll die Taschen sind, ihr Gewicht und was noch fehlt.</string>
<string name="widget_atlas_name">Weltkarte</string>
<string name="widget_atlas_description">Die Länder, in denen du warst, leuchtend auf der Karte.</string>
<string name="widget_collab_name">Reisegruppe</string>
<string name="widget_collab_description">Wer mitreist, die neuesten Nachrichten und die offene Abstimmung.</string>
<string name="widget_intent_title">Reise wählen</string>
<string name="widget_intent_description">Die Reise, die dieses Widget zeigt.</string>
<string name="widget_intent_trip">Reise</string>
<string name="widget_intent_auto">Automatisch</string>
<string name="widget_intent_auto_hint">Dieselbe Reise wie im Dashboard</string>
<string name="share_title">Zu TREK hinzufügen</string>
<string name="share_cancel">Abbrechen</string>
<string name="share_close">Schließen</string>
<string name="share_trip">Reise</string>
<string name="share_no_trips">Öffne TREK einmal, dann erscheinen hier deine Reisen.</string>
<string name="share_hint">TREK liest die Buchung und trägt sie in die Reise ein.</string>
<string name="share_later">Später wählen</string>
<string name="share_add">Hinzufügen</string>
<string name="share_add_to">Zu %1$s hinzufügen</string>
<string name="share_added">Hinzugefügt</string>
<string name="share_reading">Wird gelesen…</string>
<string name="share_too_large">Diese Datei ist zu groß für den Import.</string>
<string name="share_unsupported">Das kann TREK nicht importieren.</string>
<string name="share_failed">Das konnte nicht an TREK übergeben werden. Bitte versuch es erneut.</string>
<string name="share_now">Läuft</string>
<string name="share_past">Vorbei</string>
<string name="share_tomorrow">Morgen</string>
<string name="share_in_days">In %1$d Tagen</string>
<string name="share_kind_pass">Wallet-Pass</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-Mail</string>
<string name="share_kind_page">Webseite</string>
<string name="share_kind_text">Text</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Datei</string>
<string name="widget_empty">Öffne TREK einmal, dann erscheinen hier deine Reisen.</string>
<string name="push_channel_chat">Reise-Chat</string>
<string name="push_channel_trips">Neues zur Reise</string>
<string name="push_channel_invites">Einladungen und Freigaben</string>
<string name="push_channel_name">Allgemein</string>
<string name="push_channel_tripday">Reisetag</string>
<string name="push_fallback_body">Neue Benachrichtigung</string>
<string name="push_channel_tripday_description">Der Tag einer laufenden Reise und der nächste Stopp.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Ταξίδι</string>
<string name="widget_trip_description">Μετρά αντίστροφα ως το επόμενο ταξίδι και στη διάρκειά του δείχνει τη μέρα και τι ακολουθεί.</string>
<string name="widget_today_name">Σήμερα</string>
<string name="widget_today_description">Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.</string>
<string name="widget_bookings_name">Κρατήσεις</string>
<string name="widget_bookings_description">Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.</string>
<string name="widget_todos_name">Εκκρεμότητες</string>
<string name="widget_todos_description">Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.</string>
<string name="widget_packing_name">Αποσκευές</string>
<string name="widget_packing_description">Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα.</string>
<string name="widget_atlas_name">Παγκόσμιος χάρτης</string>
<string name="widget_atlas_description">Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη.</string>
<string name="widget_collab_name">Ομάδα ταξιδιού</string>
<string name="widget_collab_description">Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία.</string>
<string name="widget_intent_title">Επιλογή ταξιδιού</string>
<string name="widget_intent_description">Το ταξίδι που δείχνει αυτό το widget.</string>
<string name="widget_intent_trip">Ταξίδι</string>
<string name="widget_intent_auto">Αυτόματα</string>
<string name="widget_intent_auto_hint">Το ίδιο ταξίδι με τον πίνακα</string>
<string name="share_title">Προσθήκη στο TREK</string>
<string name="share_cancel">Ακύρωση</string>
<string name="share_close">Κλείσιμο</string>
<string name="share_trip">Ταξίδι</string>
<string name="share_no_trips">Ανοίξτε το TREK μία φορά και τα ταξίδια σας θα εμφανιστούν εδώ.</string>
<string name="share_hint">Το TREK διαβάζει την κράτηση και την προσθέτει στο ταξίδι.</string>
<string name="share_later">Επιλογή αργότερα</string>
<string name="share_add">Προσθήκη</string>
<string name="share_add_to">Προσθήκη στο %1$s</string>
<string name="share_added">Προστέθηκε</string>
<string name="share_reading">Ανάγνωση…</string>
<string name="share_too_large">Το αρχείο είναι πολύ μεγάλο για εισαγωγή.</string>
<string name="share_unsupported">Το TREK δεν μπορεί να το εισαγάγει.</string>
<string name="share_failed">Δεν ήταν δυνατή η μεταφορά στο TREK. Δοκιμάστε ξανά.</string>
<string name="share_now">Τώρα</string>
<string name="share_past">Παρελθόν</string>
<string name="share_tomorrow">Αύριο</string>
<string name="share_in_days">Σε %1$d ημέρες</string>
<string name="share_kind_pass">Κάρτα Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Email</string>
<string name="share_kind_page">Ιστοσελίδα</string>
<string name="share_kind_text">Κείμενο</string>
<string name="share_kind_image">Φωτογραφία</string>
<string name="share_kind_file">Αρχείο</string>
<string name="widget_empty">Ανοίξτε το TREK μία φορά και τα ταξίδια σας θα εμφανιστούν εδώ.</string>
<string name="push_channel_chat">Συνομιλία ταξιδιού</string>
<string name="push_channel_trips">Ενημερώσεις ταξιδιού</string>
<string name="push_channel_invites">Προσκλήσεις και κοινοποιήσεις</string>
<string name="push_channel_name">Γενικά</string>
<string name="push_channel_tripday">Ημέρα ταξιδιού</string>
<string name="push_fallback_body">Νέα ειδοποίηση</string>
<string name="push_channel_tripday_description">Η ημέρα ενός ταξιδιού σε εξέλιξη και η επόμενη στάση του.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Viaje</string>
<string name="widget_trip_description">Cuenta los días hasta tu próximo viaje y, durante él, muestra el día y lo que sigue.</string>
<string name="widget_today_name">Hoy</string>
<string name="widget_today_description">El plan del día como línea de tiempo, con el tiempo y la próxima parada.</string>
<string name="widget_bookings_name">Reservas</string>
<string name="widget_bookings_description">Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.</string>
<string name="widget_todos_name">Tareas</string>
<string name="widget_todos_description">Lo que aún queda para el viaje y cómo va la maleta.</string>
<string name="widget_packing_name">Equipaje</string>
<string name="widget_packing_description">Lo llenas que van las maletas, su peso y lo que aún falta.</string>
<string name="widget_atlas_name">Mapa del mundo</string>
<string name="widget_atlas_description">Los países donde has estado, brillando en el mapa.</string>
<string name="widget_collab_name">Grupo de viaje</string>
<string name="widget_collab_description">Quién viene, los últimos mensajes y la votación abierta.</string>
<string name="widget_intent_title">Elegir viaje</string>
<string name="widget_intent_description">El viaje que muestra este widget.</string>
<string name="widget_intent_trip">Viaje</string>
<string name="widget_intent_auto">Automático</string>
<string name="widget_intent_auto_hint">El mismo viaje que el panel</string>
<string name="share_title">Añadir a TREK</string>
<string name="share_cancel">Cancelar</string>
<string name="share_close">Cerrar</string>
<string name="share_trip">Viaje</string>
<string name="share_no_trips">Abre TREK una vez y tus viajes aparecerán aquí.</string>
<string name="share_hint">TREK lee la reserva y la añade al viaje.</string>
<string name="share_later">Elegir después</string>
<string name="share_add">Añadir</string>
<string name="share_add_to">Añadir a %1$s</string>
<string name="share_added">Añadido</string>
<string name="share_reading">Leyendo…</string>
<string name="share_too_large">Este archivo es demasiado grande para importarlo.</string>
<string name="share_unsupported">TREK no puede importar esto.</string>
<string name="share_failed">No se pudo pasar a TREK. Inténtalo de nuevo.</string>
<string name="share_now">En curso</string>
<string name="share_past">Pasado</string>
<string name="share_tomorrow">Mañana</string>
<string name="share_in_days">En %1$d días</string>
<string name="share_kind_pass">Pase de Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Correo</string>
<string name="share_kind_page">Página web</string>
<string name="share_kind_text">Texto</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Archivo</string>
<string name="widget_empty">Abre TREK una vez y tus viajes aparecerán aquí.</string>
<string name="push_channel_chat">Chat del viaje</string>
<string name="push_channel_trips">Novedades del viaje</string>
<string name="push_channel_invites">Invitaciones y compartidos</string>
<string name="push_channel_name">General</string>
<string name="push_channel_tripday">Día de viaje</string>
<string name="push_fallback_body">Notificación nueva</string>
<string name="push_channel_tripday_description">El día de un viaje en curso y su próxima parada.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Reis</string>
<string name="widget_trip_description">Loeb päevi järgmise reisini ja näitab reisi ajal päeva ning seda, mis järgmisena tuleb.</string>
<string name="widget_today_name">Täna</string>
<string name="widget_today_description">Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.</string>
<string name="widget_bookings_name">Broneeringud</string>
<string name="widget_bookings_description">Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.</string>
<string name="widget_todos_name">Ülesanded</string>
<string name="widget_todos_description">Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.</string>
<string name="widget_packing_name">Pakkimine</string>
<string name="widget_packing_description">Kui täis kotid on, nende kaal ja mis veel puudu on.</string>
<string name="widget_atlas_name">Maailmakaart</string>
<string name="widget_atlas_description">Riigid, kus oled käinud, kaardil helendamas.</string>
<string name="widget_collab_name">Reisiseltskond</string>
<string name="widget_collab_description">Kes tuleb kaasa, viimased sõnumid ja avatud hääletus.</string>
<string name="widget_intent_title">Vali reis</string>
<string name="widget_intent_description">Reis, mida see vidin näitab.</string>
<string name="widget_intent_trip">Reis</string>
<string name="widget_intent_auto">Automaatne</string>
<string name="widget_intent_auto_hint">Sama reis mis töölaual</string>
<string name="share_title">Lisa TREK-i</string>
<string name="share_cancel">Tühista</string>
<string name="share_close">Sulge</string>
<string name="share_trip">Reis</string>
<string name="share_no_trips">Ava TREK korra ja sinu reisid ilmuvad siia.</string>
<string name="share_hint">TREK loeb broneeringu ja lisab selle reisile.</string>
<string name="share_later">Vali hiljem</string>
<string name="share_add">Lisa</string>
<string name="share_add_to">Lisa reisile %1$s</string>
<string name="share_added">Lisatud</string>
<string name="share_reading">Loen…</string>
<string name="share_too_large">See fail on importimiseks liiga suur.</string>
<string name="share_unsupported">TREK ei saa seda importida.</string>
<string name="share_failed">Seda ei õnnestunud TREK-ile üle anda. Proovi uuesti.</string>
<string name="share_now">Praegu</string>
<string name="share_past">Möödas</string>
<string name="share_tomorrow">Homme</string>
<string name="share_in_days">%1$d päeva pärast</string>
<string name="share_kind_pass">Wallet’i pass</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-kiri</string>
<string name="share_kind_page">Veebileht</string>
<string name="share_kind_text">Tekst</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Fail</string>
<string name="widget_empty">Ava TREK korra ja sinu reisid ilmuvad siia.</string>
<string name="push_channel_chat">Reisi vestlus</string>
<string name="push_channel_trips">Reisi uudised</string>
<string name="push_channel_invites">Kutsed ja jagamised</string>
<string name="push_channel_name">Üldine</string>
<string name="push_channel_tripday">Reisipäev</string>
<string name="push_fallback_body">Uus teavitus</string>
<string name="push_channel_tripday_description">Käimasoleva reisi päev ja järgmine peatus.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Voyage</string>
<string name="widget_trip_description">Compte les jours jusqu\'au prochain voyage, puis montre la journée et la suite pendant le voyage.</string>
<string name="widget_today_name">Aujourd\'hui</string>
<string name="widget_today_description">Le programme du jour en frise, avec la météo et la prochaine étape.</string>
<string name="widget_bookings_name">Réservations</string>
<string name="widget_bookings_description">Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.</string>
<string name="widget_todos_name">À faire</string>
<string name="widget_todos_description">Ce qui reste à faire pour le voyage et où en sont les bagages.</string>
<string name="widget_packing_name">Bagages</string>
<string name="widget_packing_description">Le remplissage des sacs, leur poids et ce qui manque encore.</string>
<string name="widget_atlas_name">Carte du monde</string>
<string name="widget_atlas_description">Les pays où vous êtes allé, illuminés sur la carte.</string>
<string name="widget_collab_name">Groupe de voyage</string>
<string name="widget_collab_description">Qui vient, les derniers messages et le sondage ouvert.</string>
<string name="widget_intent_title">Choisir le voyage</string>
<string name="widget_intent_description">Le voyage affiché par ce widget.</string>
<string name="widget_intent_trip">Voyage</string>
<string name="widget_intent_auto">Automatique</string>
<string name="widget_intent_auto_hint">Le même voyage que le tableau de bord</string>
<string name="share_title">Ajouter à TREK</string>
<string name="share_cancel">Annuler</string>
<string name="share_close">Fermer</string>
<string name="share_trip">Voyage</string>
<string name="share_no_trips">Ouvrez TREK une fois et vos voyages apparaîtront ici.</string>
<string name="share_hint">TREK lit la réservation et l’ajoute au voyage.</string>
<string name="share_later">Choisir plus tard</string>
<string name="share_add">Ajouter</string>
<string name="share_add_to">Ajouter à %1$s</string>
<string name="share_added">Ajouté</string>
<string name="share_reading">Lecture…</string>
<string name="share_too_large">Ce fichier est trop volumineux pour être importé.</string>
<string name="share_unsupported">TREK ne peut pas importer ceci.</string>
<string name="share_failed">Impossible de transmettre ceci à TREK. Réessayez.</string>
<string name="share_now">En cours</string>
<string name="share_past">Passé</string>
<string name="share_tomorrow">Demain</string>
<string name="share_in_days">Dans %1$d jours</string>
<string name="share_kind_pass">Carte Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Page web</string>
<string name="share_kind_text">Texte</string>
<string name="share_kind_image">Photo</string>
<string name="share_kind_file">Fichier</string>
<string name="widget_empty">Ouvrez TREK une fois et vos voyages apparaîtront ici.</string>
<string name="push_channel_chat">Discussion du voyage</string>
<string name="push_channel_trips">Nouvelles du voyage</string>
<string name="push_channel_invites">Invitations et partages</string>
<string name="push_channel_name">Général</string>
<string name="push_channel_tripday">Jour de voyage</string>
<string name="push_fallback_body">Nouvelle notification</string>
<string name="push_channel_tripday_description">La journée d\'un voyage en cours et sa prochaine étape.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Utazás</string>
<string name="widget_trip_description">Visszaszámol a következő utazásig, útközben pedig a napot és a következő programot mutatja.</string>
<string name="widget_today_name">Ma</string>
<string name="widget_today_description">A nap terve idővonalon, az időjárással és a következő megállóval.</string>
<string name="widget_bookings_name">Foglalások</string>
<string name="widget_bookings_description">A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.</string>
<string name="widget_todos_name">Teendők</string>
<string name="widget_todos_description">Ami még hátravan az utazáshoz, és hol tart a csomagolás.</string>
<string name="widget_packing_name">Csomagolás</string>
<string name="widget_packing_description">Mennyire telik a táska, a súlya, és mi hiányzik még.</string>
<string name="widget_atlas_name">Világtérkép</string>
<string name="widget_atlas_description">Az országok, ahol jártál, világítva a térképen.</string>
<string name="widget_collab_name">Útitársak</string>
<string name="widget_collab_description">Ki jön veled, a legújabb üzenetek és a nyitott szavazás.</string>
<string name="widget_intent_title">Utazás választása</string>
<string name="widget_intent_description">Az utazás, amelyet ez a widget mutat.</string>
<string name="widget_intent_trip">Utazás</string>
<string name="widget_intent_auto">Automatikus</string>
<string name="widget_intent_auto_hint">Ugyanaz az utazás, mint az irányítópulton</string>
<string name="share_title">Hozzáadás a TREK-hez</string>
<string name="share_cancel">Mégse</string>
<string name="share_close">Bezárás</string>
<string name="share_trip">Utazás</string>
<string name="share_no_trips">Nyisd meg egyszer a TREK-et, és itt megjelennek az utazásaid.</string>
<string name="share_hint">A TREK beolvassa a foglalást, és hozzáadja az utazáshoz.</string>
<string name="share_later">Később választok</string>
<string name="share_add">Hozzáadás</string>
<string name="share_add_to">Hozzáadás ehhez: %1$s</string>
<string name="share_added">Hozzáadva</string>
<string name="share_reading">Olvasás…</string>
<string name="share_too_large">Ez a fájl túl nagy az importáláshoz.</string>
<string name="share_unsupported">Ezt a TREK nem tudja importálni.</string>
<string name="share_failed">Nem sikerült átadni a TREK-nek. Próbáld újra.</string>
<string name="share_now">Most</string>
<string name="share_past">Elmúlt</string>
<string name="share_tomorrow">Holnap</string>
<string name="share_in_days">%1$d nap múlva</string>
<string name="share_kind_pass">Wallet-kártya</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Weboldal</string>
<string name="share_kind_text">Szöveg</string>
<string name="share_kind_image">Fotó</string>
<string name="share_kind_file">Fájl</string>
<string name="widget_empty">Nyisd meg egyszer a TREK-et, és itt megjelennek az utazásaid.</string>
<string name="push_channel_chat">Utazási csevegés</string>
<string name="push_channel_trips">Utazási hírek</string>
<string name="push_channel_invites">Meghívások és megosztások</string>
<string name="push_channel_name">Általános</string>
<string name="push_channel_tripday">Utazási nap</string>
<string name="push_fallback_body">Új értesítés</string>
<string name="push_channel_tripday_description">Egy folyamatban lévő utazás napja és a következő megálló.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Perjalanan</string>
<string name="widget_trip_description">Menghitung mundur ke perjalanan berikutnya, lalu menampilkan hari dan apa yang berikutnya.</string>
<string name="widget_today_name">Hari ini</string>
<string name="widget_today_description">Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.</string>
<string name="widget_bookings_name">Pemesanan</string>
<string name="widget_bookings_description">Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.</string>
<string name="widget_todos_name">Tugas</string>
<string name="widget_todos_description">Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.</string>
<string name="widget_packing_name">Barang bawaan</string>
<string name="widget_packing_description">Seberapa penuh tas, beratnya, dan yang masih kurang.</string>
<string name="widget_atlas_name">Peta dunia</string>
<string name="widget_atlas_description">Negara yang pernah kamu kunjungi, bersinar di peta.</string>
<string name="widget_collab_name">Rombongan</string>
<string name="widget_collab_description">Siapa yang ikut, pesan terbaru, dan polling yang terbuka.</string>
<string name="widget_intent_title">Pilih perjalanan</string>
<string name="widget_intent_description">Perjalanan yang ditampilkan widget ini.</string>
<string name="widget_intent_trip">Perjalanan</string>
<string name="widget_intent_auto">Otomatis</string>
<string name="widget_intent_auto_hint">Perjalanan yang sama dengan dasbor</string>
<string name="share_title">Tambahkan ke TREK</string>
<string name="share_cancel">Batal</string>
<string name="share_close">Tutup</string>
<string name="share_trip">Perjalanan</string>
<string name="share_no_trips">Buka TREK sekali dan perjalanan Anda akan muncul di sini.</string>
<string name="share_hint">TREK membaca pemesanan dan menambahkannya ke perjalanan.</string>
<string name="share_later">Pilih nanti</string>
<string name="share_add">Tambahkan</string>
<string name="share_add_to">Tambahkan ke %1$s</string>
<string name="share_added">Ditambahkan</string>
<string name="share_reading">Membaca…</string>
<string name="share_too_large">File ini terlalu besar untuk diimpor.</string>
<string name="share_unsupported">TREK tidak dapat mengimpor ini.</string>
<string name="share_failed">Tidak dapat diteruskan ke TREK. Silakan coba lagi.</string>
<string name="share_now">Sekarang</string>
<string name="share_past">Lewat</string>
<string name="share_tomorrow">Besok</string>
<string name="share_in_days">Dalam %1$d hari</string>
<string name="share_kind_pass">Pass Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Email</string>
<string name="share_kind_page">Halaman web</string>
<string name="share_kind_text">Teks</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">File</string>
<string name="widget_empty">Buka TREK sekali dan perjalanan Anda akan muncul di sini.</string>
<string name="push_channel_chat">Obrolan perjalanan</string>
<string name="push_channel_trips">Kabar perjalanan</string>
<string name="push_channel_invites">Undangan dan berbagi</string>
<string name="push_channel_name">Umum</string>
<string name="push_channel_tripday">Hari perjalanan</string>
<string name="push_fallback_body">Notifikasi baru</string>
<string name="push_channel_tripday_description">Hari dari perjalanan yang berlangsung dan perhentian berikutnya.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Viaggio</string>
<string name="widget_trip_description">Conta i giorni al prossimo viaggio e durante il viaggio mostra la giornata e cosa viene dopo.</string>
<string name="widget_today_name">Oggi</string>
<string name="widget_today_description">Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.</string>
<string name="widget_bookings_name">Prenotazioni</string>
<string name="widget_bookings_description">I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.</string>
<string name="widget_todos_name">Da fare</string>
<string name="widget_todos_description">Cosa manca ancora per il viaggio e a che punto è la valigia.</string>
<string name="widget_packing_name">Bagagli</string>
<string name="widget_packing_description">Quanto sono piene le borse, il loro peso e cosa manca ancora.</string>
<string name="widget_atlas_name">Mappa del mondo</string>
<string name="widget_atlas_description">I paesi in cui sei stato, illuminati sulla mappa.</string>
<string name="widget_collab_name">Gruppo di viaggio</string>
<string name="widget_collab_description">Chi viene, gli ultimi messaggi e il sondaggio aperto.</string>
<string name="widget_intent_title">Scegli viaggio</string>
<string name="widget_intent_description">Il viaggio mostrato da questo widget.</string>
<string name="widget_intent_trip">Viaggio</string>
<string name="widget_intent_auto">Automatico</string>
<string name="widget_intent_auto_hint">Lo stesso viaggio della dashboard</string>
<string name="share_title">Aggiungi a TREK</string>
<string name="share_cancel">Annulla</string>
<string name="share_close">Chiudi</string>
<string name="share_trip">Viaggio</string>
<string name="share_no_trips">Apri TREK una volta e qui compariranno i tuoi viaggi.</string>
<string name="share_hint">TREK legge la prenotazione e la aggiunge al viaggio.</string>
<string name="share_later">Scegli dopo</string>
<string name="share_add">Aggiungi</string>
<string name="share_add_to">Aggiungi a %1$s</string>
<string name="share_added">Aggiunto</string>
<string name="share_reading">Lettura…</string>
<string name="share_too_large">Questo file è troppo grande da importare.</string>
<string name="share_unsupported">TREK non può importare questo contenuto.</string>
<string name="share_failed">Impossibile passarlo a TREK. Riprova.</string>
<string name="share_now">In corso</string>
<string name="share_past">Passato</string>
<string name="share_tomorrow">Domani</string>
<string name="share_in_days">Tra %1$d giorni</string>
<string name="share_kind_pass">Pass di Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Pagina web</string>
<string name="share_kind_text">Testo</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">File</string>
<string name="widget_empty">Apri TREK una volta e qui compariranno i tuoi viaggi.</string>
<string name="push_channel_chat">Chat del viaggio</string>
<string name="push_channel_trips">Novità del viaggio</string>
<string name="push_channel_invites">Inviti e condivisioni</string>
<string name="push_channel_name">Generali</string>
<string name="push_channel_tripday">Giorno di viaggio</string>
<string name="push_fallback_body">Nuova notifica</string>
<string name="push_channel_tripday_description">La giornata di un viaggio in corso e la prossima tappa.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">טיול</string>
<string name="widget_trip_description">סופר את הימים עד הטיול הבא, ובמהלכו מציג את היום ואת מה שבא אחריו.</string>
<string name="widget_today_name">היום</string>
<string name="widget_today_description">תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.</string>
<string name="widget_bookings_name">הזמנות</string>
<string name="widget_bookings_description">הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.</string>
<string name="widget_todos_name">משימות</string>
<string name="widget_todos_description">מה עוד פתוח לטיול וכמה התקדמה האריזה.</string>
<string name="widget_packing_name">אריזה</string>
<string name="widget_packing_description">כמה מלאים התיקים, המשקל שלהם ומה עוד חסר.</string>
<string name="widget_atlas_name">מפת העולם</string>
<string name="widget_atlas_description">המדינות שביקרת בהן, זוהרות על המפה.</string>
<string name="widget_collab_name">קבוצת הטיול</string>
<string name="widget_collab_description">מי מצטרף, ההודעות האחרונות והסקר הפתוח.</string>
<string name="widget_intent_title">בחירת טיול</string>
<string name="widget_intent_description">הטיול שהווידג׳ט הזה מציג.</string>
<string name="widget_intent_trip">טיול</string>
<string name="widget_intent_auto">אוטומטי</string>
<string name="widget_intent_auto_hint">אותו טיול כמו בלוח הבקרה</string>
<string name="share_title">הוספה ל-TREK</string>
<string name="share_cancel">ביטול</string>
<string name="share_close">סגירה</string>
<string name="share_trip">טיול</string>
<string name="share_no_trips">פתחו את TREK פעם אחת והטיולים שלכם יופיעו כאן.</string>
<string name="share_hint">TREK קורא את ההזמנה ומוסיף אותה לטיול.</string>
<string name="share_later">לבחור אחר כך</string>
<string name="share_add">הוספה</string>
<string name="share_add_to">הוספה אל %1$s</string>
<string name="share_added">נוסף</string>
<string name="share_reading">קורא…</string>
<string name="share_too_large">הקובץ גדול מדי לייבוא.</string>
<string name="share_unsupported">TREK לא יכול לייבא את זה.</string>
<string name="share_failed">לא ניתן היה להעביר את זה ל-TREK. אנא נסו שוב.</string>
<string name="share_now">עכשיו</string>
<string name="share_past">עבר</string>
<string name="share_tomorrow">מחר</string>
<string name="share_in_days">בעוד %1$d ימים</string>
<string name="share_kind_pass">כרטיס Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">דוא״ל</string>
<string name="share_kind_page">דף אינטרנט</string>
<string name="share_kind_text">טקסט</string>
<string name="share_kind_image">תמונה</string>
<string name="share_kind_file">קובץ</string>
<string name="widget_empty">פתחו את TREK פעם אחת והטיולים שלכם יופיעו כאן.</string>
<string name="push_channel_chat">צ׳אט הטיול</string>
<string name="push_channel_trips">עדכוני הטיול</string>
<string name="push_channel_invites">הזמנות ושיתופים</string>
<string name="push_channel_name">כללי</string>
<string name="push_channel_tripday">יום טיול</string>
<string name="push_fallback_body">התראה חדשה</string>
<string name="push_channel_tripday_description">היום של טיול פעיל והעצירה הבאה שלו.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">旅行</string>
<string name="widget_trip_description">次の旅行までをカウントダウンし、旅行中はその日の予定と次の行き先を表示します。</string>
<string name="widget_today_name">今日</string>
<string name="widget_today_description">今日の予定をタイムラインで、天気と次の予定とともに表示します。</string>
<string name="widget_bookings_name">予約</string>
<string name="widget_bookings_description">次のフライト、列車、ホテル、レストランとその日時。</string>
<string name="widget_todos_name">やること</string>
<string name="widget_todos_description">旅行のために残っていることと、荷造りの進み具合。</string>
<string name="widget_packing_name">持ち物</string>
<string name="widget_packing_description">バッグの詰め具合と重さ、まだ足りないもの。</string>
<string name="widget_atlas_name">世界地図</string>
<string name="widget_atlas_description">訪れた国が地図上で光ります。</string>
<string name="widget_collab_name">旅行グループ</string>
<string name="widget_collab_description">一緒に行くメンバー、最新のメッセージ、受付中の投票。</string>
<string name="widget_intent_title">旅行を選択</string>
<string name="widget_intent_description">このウィジェットに表示する旅行。</string>
<string name="widget_intent_trip">旅行</string>
<string name="widget_intent_auto">自動</string>
<string name="widget_intent_auto_hint">ダッシュボードと同じ旅行</string>
<string name="share_title">TREKに追加</string>
<string name="share_cancel">キャンセル</string>
<string name="share_close">閉じる</string>
<string name="share_trip">旅行</string>
<string name="share_no_trips">一度TREKを開くと、ここに旅行が表示されます。</string>
<string name="share_hint">TREKが予約を読み取り、旅行に追加します。</string>
<string name="share_later">あとで選ぶ</string>
<string name="share_add">追加</string>
<string name="share_add_to">%1$sに追加</string>
<string name="share_added">追加しました</string>
<string name="share_reading">読み込み中…</string>
<string name="share_too_large">このファイルは大きすぎて読み込めません。</string>
<string name="share_unsupported">TREKではこれを読み込めません。</string>
<string name="share_failed">TREKに渡せませんでした。もう一度お試しください。</string>
<string name="share_now">旅行中</string>
<string name="share_past">終了</string>
<string name="share_tomorrow">明日</string>
<string name="share_in_days">%1$d日後</string>
<string name="share_kind_pass">Walletパス</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">メール</string>
<string name="share_kind_page">Webページ</string>
<string name="share_kind_text">テキスト</string>
<string name="share_kind_image">写真</string>
<string name="share_kind_file">ファイル</string>
<string name="widget_empty">一度TREKを開くと、ここに旅行が表示されます。</string>
<string name="push_channel_chat">旅行チャット</string>
<string name="push_channel_trips">旅行の更新</string>
<string name="push_channel_invites">招待と共有</string>
<string name="push_channel_name">一般</string>
<string name="push_channel_tripday">旅行日</string>
<string name="push_fallback_body">新しい通知</string>
<string name="push_channel_tripday_description">進行中の旅行の一日と次の立ち寄り先。</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">여행</string>
<string name="widget_trip_description">다음 여행까지 남은 날을 세고, 여행 중에는 오늘과 다음 일정을 보여 줍니다.</string>
<string name="widget_today_name">오늘</string>
<string name="widget_today_description">오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.</string>
<string name="widget_bookings_name">예약</string>
<string name="widget_bookings_description">다가오는 항공편, 기차, 호텔, 식당과 그 시간.</string>
<string name="widget_todos_name">할 일</string>
<string name="widget_todos_description">여행을 위해 남은 일과 짐 싸기 진행 상황.</string>
<string name="widget_packing_name">짐 싸기</string>
<string name="widget_packing_description">가방이 얼마나 찼는지, 무게, 아직 빠진 것.</string>
<string name="widget_atlas_name">세계 지도</string>
<string name="widget_atlas_description">다녀온 나라가 지도 위에서 빛납니다.</string>
<string name="widget_collab_name">여행 그룹</string>
<string name="widget_collab_description">함께 가는 사람, 최근 메시지, 진행 중인 투표.</string>
<string name="widget_intent_title">여행 선택</string>
<string name="widget_intent_description">이 위젯에 표시할 여행.</string>
<string name="widget_intent_trip">여행</string>
<string name="widget_intent_auto">자동</string>
<string name="widget_intent_auto_hint">대시보드와 같은 여행</string>
<string name="share_title">TREK에 추가</string>
<string name="share_cancel">취소</string>
<string name="share_close">닫기</string>
<string name="share_trip">여행</string>
<string name="share_no_trips">TREK을 한 번 열면 여기에 여행이 표시됩니다.</string>
<string name="share_hint">TREK이 예약을 읽고 여행에 추가합니다.</string>
<string name="share_later">나중에 선택</string>
<string name="share_add">추가</string>
<string name="share_add_to">%1$s에 추가</string>
<string name="share_added">추가됨</string>
<string name="share_reading">읽는 중…</string>
<string name="share_too_large">이 파일은 가져오기에는 너무 큽니다.</string>
<string name="share_unsupported">TREK에서 가져올 수 없는 항목입니다.</string>
<string name="share_failed">TREK에 전달하지 못했습니다. 다시 시도하세요.</string>
<string name="share_now">진행 중</string>
<string name="share_past">지난 여행</string>
<string name="share_tomorrow">내일</string>
<string name="share_in_days">%1$d일 후</string>
<string name="share_kind_pass">Wallet 패스</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">이메일</string>
<string name="share_kind_page">웹 페이지</string>
<string name="share_kind_text">텍스트</string>
<string name="share_kind_image">사진</string>
<string name="share_kind_file">파일</string>
<string name="widget_empty">TREK을 한 번 열면 여기에 여행이 표시됩니다.</string>
<string name="push_channel_chat">여행 채팅</string>
<string name="push_channel_trips">여행 소식</string>
<string name="push_channel_invites">초대 및 공유</string>
<string name="push_channel_name">일반</string>
<string name="push_channel_tripday">여행일</string>
<string name="push_fallback_body">새 알림</string>
<string name="push_channel_tripday_description">진행 중인 여행의 하루와 다음 장소.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Reis</string>
<string name="widget_trip_description">Telt af naar je volgende reis en toont onderweg de dag en wat er hierna komt.</string>
<string name="widget_today_name">Vandaag</string>
<string name="widget_today_description">Het dagplan als tijdlijn, met het weer en de volgende stop.</string>
<string name="widget_bookings_name">Boekingen</string>
<string name="widget_bookings_description">Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.</string>
<string name="widget_todos_name">Taken</string>
<string name="widget_todos_description">Wat er nog openstaat voor de reis en hoe ver het inpakken is.</string>
<string name="widget_packing_name">Bagage</string>
<string name="widget_packing_description">Hoe vol de tassen zijn, hun gewicht en wat er nog mist.</string>
<string name="widget_atlas_name">Wereldkaart</string>
<string name="widget_atlas_description">De landen waar je bent geweest, oplichtend op de kaart.</string>
<string name="widget_collab_name">Reisgezelschap</string>
<string name="widget_collab_description">Wie er meegaat, de nieuwste berichten en de open peiling.</string>
<string name="widget_intent_title">Reis kiezen</string>
<string name="widget_intent_description">De reis die deze widget toont.</string>
<string name="widget_intent_trip">Reis</string>
<string name="widget_intent_auto">Automatisch</string>
<string name="widget_intent_auto_hint">Dezelfde reis als het dashboard</string>
<string name="share_title">Toevoegen aan TREK</string>
<string name="share_cancel">Annuleer</string>
<string name="share_close">Sluit</string>
<string name="share_trip">Reis</string>
<string name="share_no_trips">Open TREK één keer, dan verschijnen je reizen hier.</string>
<string name="share_hint">TREK leest de boeking en zet hem in de reis.</string>
<string name="share_later">Later kiezen</string>
<string name="share_add">Toevoegen</string>
<string name="share_add_to">Toevoegen aan %1$s</string>
<string name="share_added">Toegevoegd</string>
<string name="share_reading">Lezen…</string>
<string name="share_too_large">Dit bestand is te groot om te importeren.</string>
<string name="share_unsupported">Dit kan TREK niet importeren.</string>
<string name="share_failed">Dit kon niet aan TREK worden doorgegeven. Probeer het opnieuw.</string>
<string name="share_now">Nu</string>
<string name="share_past">Voorbij</string>
<string name="share_tomorrow">Morgen</string>
<string name="share_in_days">Over %1$d dagen</string>
<string name="share_kind_pass">Wallet-pas</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Webpagina</string>
<string name="share_kind_text">Tekst</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Bestand</string>
<string name="widget_empty">Open TREK één keer, dan verschijnen je reizen hier.</string>
<string name="push_channel_chat">Reischat</string>
<string name="push_channel_trips">Reisupdates</string>
<string name="push_channel_invites">Uitnodigingen en delen</string>
<string name="push_channel_name">Algemeen</string>
<string name="push_channel_tripday">Reisdag</string>
<string name="push_fallback_body">Nieuwe melding</string>
<string name="push_channel_tripday_description">De dag van een lopende reis en de volgende stop.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Podróż</string>
<string name="widget_trip_description">Odlicza dni do następnej podróży, a w trakcie pokazuje dzień i to, co dalej.</string>
<string name="widget_today_name">Dziś</string>
<string name="widget_today_description">Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.</string>
<string name="widget_bookings_name">Rezerwacje</string>
<string name="widget_bookings_description">Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.</string>
<string name="widget_todos_name">Zadania</string>
<string name="widget_todos_description">Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.</string>
<string name="widget_packing_name">Pakowanie</string>
<string name="widget_packing_description">Jak pełne są torby, ich waga i czego jeszcze brakuje.</string>
<string name="widget_atlas_name">Mapa świata</string>
<string name="widget_atlas_description">Kraje, w których byłeś, świecące na mapie.</string>
<string name="widget_collab_name">Grupa podróży</string>
<string name="widget_collab_description">Kto jedzie, najnowsze wiadomości i otwarta ankieta.</string>
<string name="widget_intent_title">Wybierz podróż</string>
<string name="widget_intent_description">Podróż, którą pokazuje ten widżet.</string>
<string name="widget_intent_trip">Podróż</string>
<string name="widget_intent_auto">Automatycznie</string>
<string name="widget_intent_auto_hint">Ta sama podróż co na pulpicie</string>
<string name="share_title">Dodaj do TREK</string>
<string name="share_cancel">Anuluj</string>
<string name="share_close">Zamknij</string>
<string name="share_trip">Podróż</string>
<string name="share_no_trips">Otwórz TREK raz, a Twoje podróże pojawią się tutaj.</string>
<string name="share_hint">TREK odczyta rezerwację i doda ją do podróży.</string>
<string name="share_later">Wybierz później</string>
<string name="share_add">Dodaj</string>
<string name="share_add_to">Dodaj do %1$s</string>
<string name="share_added">Dodano</string>
<string name="share_reading">Odczytywanie…</string>
<string name="share_too_large">Ten plik jest za duży do zaimportowania.</string>
<string name="share_unsupported">TREK nie może tego zaimportować.</string>
<string name="share_failed">Nie udało się przekazać tego do TREK. Spróbuj ponownie.</string>
<string name="share_now">Teraz</string>
<string name="share_past">Minęła</string>
<string name="share_tomorrow">Jutro</string>
<string name="share_in_days">Za %1$d dni</string>
<string name="share_kind_pass">Karta Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Strona</string>
<string name="share_kind_text">Tekst</string>
<string name="share_kind_image">Zdjęcie</string>
<string name="share_kind_file">Plik</string>
<string name="widget_empty">Otwórz TREK raz, a Twoje podróże pojawią się tutaj.</string>
<string name="push_channel_chat">Czat podróży</string>
<string name="push_channel_trips">Nowości o podróży</string>
<string name="push_channel_invites">Zaproszenia i udostępnienia</string>
<string name="push_channel_name">Ogólne</string>
<string name="push_channel_tripday">Dzień podróży</string>
<string name="push_fallback_body">Nowe powiadomienie</string>
<string name="push_channel_tripday_description">Dzień trwającej podróży i jej następny przystanek.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Viagem</string>
<string name="widget_trip_description">Conta os dias até a próxima viagem e, durante ela, mostra o dia e o que vem a seguir.</string>
<string name="widget_today_name">Hoje</string>
<string name="widget_today_description">O plano do dia como linha do tempo, com o clima e a próxima parada.</string>
<string name="widget_bookings_name">Reservas</string>
<string name="widget_bookings_description">Seus próximos voos, trens, hotéis e mesas, e quando acontecem.</string>
<string name="widget_todos_name">Tarefas</string>
<string name="widget_todos_description">O que ainda falta para a viagem e quanto da mala já está pronto.</string>
<string name="widget_packing_name">Bagagem</string>
<string name="widget_packing_description">Quão cheias estão as malas, o peso e o que ainda falta.</string>
<string name="widget_atlas_name">Mapa-múndi</string>
<string name="widget_atlas_description">Os países em que você esteve, brilhando no mapa.</string>
<string name="widget_collab_name">Grupo de viagem</string>
<string name="widget_collab_description">Quem vai junto, as mensagens recentes e a votação aberta.</string>
<string name="widget_intent_title">Escolher viagem</string>
<string name="widget_intent_description">A viagem que este widget mostra.</string>
<string name="widget_intent_trip">Viagem</string>
<string name="widget_intent_auto">Automático</string>
<string name="widget_intent_auto_hint">A mesma viagem do painel</string>
<string name="share_title">Adicionar ao TREK</string>
<string name="share_cancel">Cancelar</string>
<string name="share_close">Fechar</string>
<string name="share_trip">Viagem</string>
<string name="share_no_trips">Abra o TREK uma vez e suas viagens aparecerão aqui.</string>
<string name="share_hint">O TREK lê a reserva e a adiciona à viagem.</string>
<string name="share_later">Escolher depois</string>
<string name="share_add">Adicionar</string>
<string name="share_add_to">Adicionar a %1$s</string>
<string name="share_added">Adicionado</string>
<string name="share_reading">Lendo…</string>
<string name="share_too_large">Este arquivo é grande demais para importar.</string>
<string name="share_unsupported">O TREK não consegue importar isto.</string>
<string name="share_failed">Não foi possível passar isto ao TREK. Tente novamente.</string>
<string name="share_now">Agora</string>
<string name="share_past">Passada</string>
<string name="share_tomorrow">Amanhã</string>
<string name="share_in_days">Em %1$d dias</string>
<string name="share_kind_pass">Passe do Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Página web</string>
<string name="share_kind_text">Texto</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Arquivo</string>
<string name="widget_empty">Abra o TREK uma vez e suas viagens aparecerão aqui.</string>
<string name="push_channel_chat">Chat da viagem</string>
<string name="push_channel_trips">Novidades da viagem</string>
<string name="push_channel_invites">Convites e compartilhamentos</string>
<string name="push_channel_name">Geral</string>
<string name="push_channel_tripday">Dia de viagem</string>
<string name="push_fallback_body">Nova notificação</string>
<string name="push_channel_tripday_description">O dia de uma viagem em andamento e sua próxima parada.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Поездка</string>
<string name="widget_trip_description">Отсчитывает дни до следующей поездки, а в пути показывает день и что дальше.</string>
<string name="widget_today_name">Сегодня</string>
<string name="widget_today_description">План дня в виде ленты времени, с погодой и следующей остановкой.</string>
<string name="widget_bookings_name">Бронирования</string>
<string name="widget_bookings_description">Ближайшие рейсы, поезда, отели и столики и когда они.</string>
<string name="widget_todos_name">Задачи</string>
<string name="widget_todos_description">Что ещё осталось сделать к поездке и как идут сборы.</string>
<string name="widget_packing_name">Сборы</string>
<string name="widget_packing_description">Насколько заполнены сумки, их вес и чего ещё не хватает.</string>
<string name="widget_atlas_name">Карта мира</string>
<string name="widget_atlas_description">Страны, где вы были, светятся на карте.</string>
<string name="widget_collab_name">Группа поездки</string>
<string name="widget_collab_description">Кто едет, последние сообщения и открытый опрос.</string>
<string name="widget_intent_title">Выбрать поездку</string>
<string name="widget_intent_description">Поездка, которую показывает этот виджет.</string>
<string name="widget_intent_trip">Поездка</string>
<string name="widget_intent_auto">Автоматически</string>
<string name="widget_intent_auto_hint">Та же поездка, что и на главной</string>
<string name="share_title">Добавить в TREK</string>
<string name="share_cancel">Отменить</string>
<string name="share_close">Закрыть</string>
<string name="share_trip">Поездка</string>
<string name="share_no_trips">Откройте TREK один раз, и ваши поездки появятся здесь.</string>
<string name="share_hint">TREK прочитает бронь и добавит её в поездку.</string>
<string name="share_later">Выбрать позже</string>
<string name="share_add">Добавить</string>
<string name="share_add_to">Добавить в «%1$s»</string>
<string name="share_added">Добавлено</string>
<string name="share_reading">Чтение…</string>
<string name="share_too_large">Файл слишком большой для импорта.</string>
<string name="share_unsupported">TREK не может это импортировать.</string>
<string name="share_failed">Не удалось передать это в TREK. Повторите попытку.</string>
<string name="share_now">Сейчас</string>
<string name="share_past">Прошла</string>
<string name="share_tomorrow">Завтра</string>
<string name="share_in_days">Через %1$d дн.</string>
<string name="share_kind_pass">Карта Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Письмо</string>
<string name="share_kind_page">Веб-страница</string>
<string name="share_kind_text">Текст</string>
<string name="share_kind_image">Фото</string>
<string name="share_kind_file">Файл</string>
<string name="widget_empty">Откройте TREK один раз, и ваши поездки появятся здесь.</string>
<string name="push_channel_chat">Чат поездки</string>
<string name="push_channel_trips">Новости поездки</string>
<string name="push_channel_invites">Приглашения и доступ</string>
<string name="push_channel_name">Общие</string>
<string name="push_channel_tripday">День поездки</string>
<string name="push_fallback_body">Новое уведомление</string>
<string name="push_channel_tripday_description">День текущей поездки и следующая остановка.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Cesta</string>
<string name="widget_trip_description">Odpočítava dni do ďalšej cesty a počas nej ukazuje deň a čo príde ďalej.</string>
<string name="widget_today_name">Dnes</string>
<string name="widget_today_description">Plán dňa ako časová os, s počasím a ďalšou zastávkou.</string>
<string name="widget_bookings_name">Rezervácie</string>
<string name="widget_bookings_description">Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.</string>
<string name="widget_todos_name">Úlohy</string>
<string name="widget_todos_description">Čo ešte zostáva na cestu a ako ďaleko je balenie.</string>
<string name="widget_packing_name">Balenie</string>
<string name="widget_packing_description">Aké plné sú tašky, ich váha a čo ešte chýba.</string>
<string name="widget_atlas_name">Mapa sveta</string>
<string name="widget_atlas_description">Krajiny, kde si bol, žiariace na mape.</string>
<string name="widget_collab_name">Cestovná skupina</string>
<string name="widget_collab_description">Kto ide s tebou, najnovšie správy a otvorené hlasovanie.</string>
<string name="widget_intent_title">Vybrať cestu</string>
<string name="widget_intent_description">Cesta, ktorú zobrazuje tento widget.</string>
<string name="widget_intent_trip">Cesta</string>
<string name="widget_intent_auto">Automaticky</string>
<string name="widget_intent_auto_hint">Rovnaká cesta ako na nástenke</string>
<string name="share_title">Pridať do TREK</string>
<string name="share_cancel">Zrušiť</string>
<string name="share_close">Zavrieť</string>
<string name="share_trip">Cesta</string>
<string name="share_no_trips">Raz otvorte TREK a vaše cesty sa zobrazia tu.</string>
<string name="share_hint">TREK prečíta rezerváciu a pridá ju k ceste.</string>
<string name="share_later">Vybrať neskôr</string>
<string name="share_add">Pridať</string>
<string name="share_add_to">Pridať do %1$s</string>
<string name="share_added">Pridané</string>
<string name="share_reading">Načítava sa…</string>
<string name="share_too_large">Tento súbor je na import príliš veľký.</string>
<string name="share_unsupported">Toto TREK nevie importovať.</string>
<string name="share_failed">Nepodarilo sa to odovzdať do TREK. Skúste to znova.</string>
<string name="share_now">Teraz</string>
<string name="share_past">Prebehla</string>
<string name="share_tomorrow">Zajtra</string>
<string name="share_in_days">O %1$d dní</string>
<string name="share_kind_pass">Karta Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-mail</string>
<string name="share_kind_page">Webová stránka</string>
<string name="share_kind_text">Text</string>
<string name="share_kind_image">Fotka</string>
<string name="share_kind_file">Súbor</string>
<string name="widget_empty">Raz otvorte TREK a vaše cesty sa zobrazia tu.</string>
<string name="push_channel_chat">Chat výletu</string>
<string name="push_channel_trips">Novinky z výletu</string>
<string name="push_channel_invites">Pozvánky a zdieľania</string>
<string name="push_channel_name">Všeobecné</string>
<string name="push_channel_tripday">Cestovný deň</string>
<string name="push_fallback_body">Nové upozornenie</string>
<string name="push_channel_tripday_description">Deň prebiehajúceho výletu a jeho ďalšia zastávka.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Resa</string>
<string name="widget_trip_description">Räknar ner till nästa resa och visar under resan dagen och vad som kommer härnäst.</string>
<string name="widget_today_name">Idag</string>
<string name="widget_today_description">Dagens plan som tidslinje, med vädret och nästa stopp.</string>
<string name="widget_bookings_name">Bokningar</string>
<string name="widget_bookings_description">Dina nästa flyg, tåg, hotell och bord, och när de är.</string>
<string name="widget_todos_name">Att göra</string>
<string name="widget_todos_description">Vad som återstår inför resan och hur långt packningen har kommit.</string>
<string name="widget_packing_name">Packning</string>
<string name="widget_packing_description">Hur fulla väskorna är, deras vikt och vad som fortfarande saknas.</string>
<string name="widget_atlas_name">Världskarta</string>
<string name="widget_atlas_description">Länderna du har besökt, lysande på kartan.</string>
<string name="widget_collab_name">Resesällskap</string>
<string name="widget_collab_description">Vem som följer med, de senaste meddelandena och den öppna omröstningen.</string>
<string name="widget_intent_title">Välj resa</string>
<string name="widget_intent_description">Resan som den här widgeten visar.</string>
<string name="widget_intent_trip">Resa</string>
<string name="widget_intent_auto">Automatiskt</string>
<string name="widget_intent_auto_hint">Samma resa som på startsidan</string>
<string name="share_title">Lägg till i TREK</string>
<string name="share_cancel">Avbryt</string>
<string name="share_close">Stäng</string>
<string name="share_trip">Resa</string>
<string name="share_no_trips">Öppna TREK en gång så visas dina resor här.</string>
<string name="share_hint">TREK läser bokningen och lägger till den i resan.</string>
<string name="share_later">Välj senare</string>
<string name="share_add">Lägg till</string>
<string name="share_add_to">Lägg till i %1$s</string>
<string name="share_added">Tillagd</string>
<string name="share_reading">Läser…</string>
<string name="share_too_large">Filen är för stor för att importeras.</string>
<string name="share_unsupported">Det här kan TREK inte importera.</string>
<string name="share_failed">Det gick inte att lämna över till TREK. Försök igen.</string>
<string name="share_now">Nu</string>
<string name="share_past">Förbi</string>
<string name="share_tomorrow">I morgon</string>
<string name="share_in_days">Om %1$d dagar</string>
<string name="share_kind_pass">Wallet-kort</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Mejl</string>
<string name="share_kind_page">Webbsida</string>
<string name="share_kind_text">Text</string>
<string name="share_kind_image">Foto</string>
<string name="share_kind_file">Fil</string>
<string name="widget_empty">Öppna TREK en gång så visas dina resor här.</string>
<string name="push_channel_chat">Resechatt</string>
<string name="push_channel_trips">Nytt om resan</string>
<string name="push_channel_invites">Inbjudningar och delningar</string>
<string name="push_channel_name">Allmänt</string>
<string name="push_channel_tripday">Resdag</string>
<string name="push_fallback_body">Ny avisering</string>
<string name="push_channel_tripday_description">Dagen för en pågående resa och nästa stopp.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">ทริป</string>
<string name="widget_trip_description">นับถอยหลังสู่ทริปถัดไป และระหว่างทริปแสดงวันนี้และสิ่งที่จะตามมา</string>
<string name="widget_today_name">วันนี้</string>
<string name="widget_today_description">แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป</string>
<string name="widget_bookings_name">การจอง</string>
<string name="widget_bookings_description">เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ</string>
<string name="widget_todos_name">สิ่งที่ต้องทำ</string>
<string name="widget_todos_description">สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า</string>
<string name="widget_packing_name">จัดกระเป๋า</string>
<string name="widget_packing_description">กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด</string>
<string name="widget_atlas_name">แผนที่โลก</string>
<string name="widget_atlas_description">ประเทศที่คุณเคยไป ส่องแสงบนแผนที่</string>
<string name="widget_collab_name">กลุ่มเดินทาง</string>
<string name="widget_collab_description">ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่</string>
<string name="widget_intent_title">เลือกทริป</string>
<string name="widget_intent_description">ทริปที่วิดเจ็ตนี้แสดง</string>
<string name="widget_intent_trip">ทริป</string>
<string name="widget_intent_auto">อัตโนมัติ</string>
<string name="widget_intent_auto_hint">ทริปเดียวกับแดชบอร์ด</string>
<string name="share_title">เพิ่มลงใน TREK</string>
<string name="share_cancel">ยกเลิก</string>
<string name="share_close">ปิด</string>
<string name="share_trip">ทริป</string>
<string name="share_no_trips">เปิด TREK หนึ่งครั้ง แล้วทริปของคุณจะแสดงที่นี่</string>
<string name="share_hint">TREK จะอ่านการจองและเพิ่มลงในทริป</string>
<string name="share_later">เลือกภายหลัง</string>
<string name="share_add">เพิ่ม</string>
<string name="share_add_to">เพิ่มลงใน %1$s</string>
<string name="share_added">เพิ่มแล้ว</string>
<string name="share_reading">กำลังอ่าน…</string>
<string name="share_too_large">ไฟล์นี้ใหญ่เกินไปสำหรับการนำเข้า</string>
<string name="share_unsupported">TREK นำเข้ารายการนี้ไม่ได้</string>
<string name="share_failed">ส่งต่อให้ TREK ไม่สำเร็จ โปรดลองอีกครั้ง</string>
<string name="share_now">ตอนนี้</string>
<string name="share_past">ผ่านไปแล้ว</string>
<string name="share_tomorrow">พรุ่งนี้</string>
<string name="share_in_days">อีก %1$d วัน</string>
<string name="share_kind_pass">บัตร Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">อีเมล</string>
<string name="share_kind_page">หน้าเว็บ</string>
<string name="share_kind_text">ข้อความ</string>
<string name="share_kind_image">รูปภาพ</string>
<string name="share_kind_file">ไฟล์</string>
<string name="widget_empty">เปิด TREK หนึ่งครั้ง แล้วทริปของคุณจะแสดงที่นี่</string>
<string name="push_channel_chat">แชทการเดินทาง</string>
<string name="push_channel_trips">อัปเดตการเดินทาง</string>
<string name="push_channel_invites">คำเชิญและการแชร์</string>
<string name="push_channel_name">ทั่วไป</string>
<string name="push_channel_tripday">วันเดินทาง</string>
<string name="push_fallback_body">การแจ้งเตือนใหม่</string>
<string name="push_channel_tripday_description">วันของทริปที่กำลังดำเนินอยู่และจุดหมายถัดไป</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Seyahat</string>
<string name="widget_trip_description">Sonraki seyahate kalan günleri sayar, yolculukta günü ve sırada ne olduğunu gösterir.</string>
<string name="widget_today_name">Bugün</string>
<string name="widget_today_description">Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.</string>
<string name="widget_bookings_name">Rezervasyonlar</string>
<string name="widget_bookings_description">Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.</string>
<string name="widget_todos_name">Yapılacaklar</string>
<string name="widget_todos_description">Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.</string>
<string name="widget_packing_name">Bavul</string>
<string name="widget_packing_description">Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler.</string>
<string name="widget_atlas_name">Dünya haritası</string>
<string name="widget_atlas_description">Bulunduğun ülkeler haritada parlıyor.</string>
<string name="widget_collab_name">Seyahat grubu</string>
<string name="widget_collab_description">Kimler geliyor, son mesajlar ve açık anket.</string>
<string name="widget_intent_title">Seyahat seç</string>
<string name="widget_intent_description">Bu widget\'ın gösterdiği seyahat.</string>
<string name="widget_intent_trip">Seyahat</string>
<string name="widget_intent_auto">Otomatik</string>
<string name="widget_intent_auto_hint">Paneldeki seyahatin aynısı</string>
<string name="share_title">TREK’e ekle</string>
<string name="share_cancel">Vazgeç</string>
<string name="share_close">Kapat</string>
<string name="share_trip">Seyahat</string>
<string name="share_no_trips">TREK’i bir kez açın, seyahatleriniz burada görünür.</string>
<string name="share_hint">TREK rezervasyonu okur ve seyahate ekler.</string>
<string name="share_later">Sonra seç</string>
<string name="share_add">Ekle</string>
<string name="share_add_to">%1$s seyahatine ekle</string>
<string name="share_added">Eklendi</string>
<string name="share_reading">Okunuyor…</string>
<string name="share_too_large">Bu dosya içe aktarmak için çok büyük.</string>
<string name="share_unsupported">TREK bunu içe aktaramaz.</string>
<string name="share_failed">Bu TREK’e aktarılamadı. Lütfen tekrar deneyin.</string>
<string name="share_now">Şimdi</string>
<string name="share_past">Geçti</string>
<string name="share_tomorrow">Yarın</string>
<string name="share_in_days">%1$d gün sonra</string>
<string name="share_kind_pass">Wallet kartı</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">E-posta</string>
<string name="share_kind_page">Web sayfası</string>
<string name="share_kind_text">Metin</string>
<string name="share_kind_image">Fotoğraf</string>
<string name="share_kind_file">Dosya</string>
<string name="widget_empty">TREK’i bir kez açın, seyahatleriniz burada görünür.</string>
<string name="push_channel_chat">Seyahat sohbeti</string>
<string name="push_channel_trips">Seyahat güncellemeleri</string>
<string name="push_channel_invites">Davetler ve paylaşımlar</string>
<string name="push_channel_name">Genel</string>
<string name="push_channel_tripday">Seyahat günü</string>
<string name="push_fallback_body">Yeni bildirim</string>
<string name="push_channel_tripday_description">Süren bir seyahatin günü ve sonraki durağı.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Подорож</string>
<string name="widget_trip_description">Відлічує дні до наступної подорожі, а в дорозі показує день і що далі.</string>
<string name="widget_today_name">Сьогодні</string>
<string name="widget_today_description">План дня у вигляді стрічки часу, з погодою і наступною зупинкою.</string>
<string name="widget_bookings_name">Бронювання</string>
<string name="widget_bookings_description">Найближчі рейси, поїзди, готелі й столики та коли вони.</string>
<string name="widget_todos_name">Завдання</string>
<string name="widget_todos_description">Що ще лишилося зробити до подорожі і як просувається пакування.</string>
<string name="widget_packing_name">Пакування</string>
<string name="widget_packing_description">Наскільки заповнені сумки, їхня вага і чого ще бракує.</string>
<string name="widget_atlas_name">Мапа світу</string>
<string name="widget_atlas_description">Країни, де ви були, світяться на мапі.</string>
<string name="widget_collab_name">Група подорожі</string>
<string name="widget_collab_description">Хто їде, останні повідомлення і відкрите опитування.</string>
<string name="widget_intent_title">Вибрати подорож</string>
<string name="widget_intent_description">Подорож, яку показує цей віджет.</string>
<string name="widget_intent_trip">Подорож</string>
<string name="widget_intent_auto">Автоматично</string>
<string name="widget_intent_auto_hint">Та сама подорож, що й на панелі</string>
<string name="share_title">Додати до TREK</string>
<string name="share_cancel">Скасувати</string>
<string name="share_close">Закрити</string>
<string name="share_trip">Подорож</string>
<string name="share_no_trips">Відкрийте TREK один раз, і ваші подорожі з’являться тут.</string>
<string name="share_hint">TREK прочитає бронювання й додасть його до подорожі.</string>
<string name="share_later">Вибрати пізніше</string>
<string name="share_add">Додати</string>
<string name="share_add_to">Додати до «%1$s»</string>
<string name="share_added">Додано</string>
<string name="share_reading">Читання…</string>
<string name="share_too_large">Файл завеликий для імпорту.</string>
<string name="share_unsupported">TREK не може це імпортувати.</string>
<string name="share_failed">Не вдалося передати це до TREK. Повторіть спробу.</string>
<string name="share_now">Зараз</string>
<string name="share_past">Минула</string>
<string name="share_tomorrow">Завтра</string>
<string name="share_in_days">Через %1$d дн.</string>
<string name="share_kind_pass">Картка Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Лист</string>
<string name="share_kind_page">Вебсторінка</string>
<string name="share_kind_text">Текст</string>
<string name="share_kind_image">Фото</string>
<string name="share_kind_file">Файл</string>
<string name="widget_empty">Відкрийте TREK один раз, і ваші подорожі з’являться тут.</string>
<string name="push_channel_chat">Чат подорожі</string>
<string name="push_channel_trips">Новини подорожі</string>
<string name="push_channel_invites">Запрошення та доступ</string>
<string name="push_channel_name">Загальні</string>
<string name="push_channel_tripday">День подорожі</string>
<string name="push_fallback_body">Нове сповіщення</string>
<string name="push_channel_tripday_description">День поточної подорожі та наступна зупинка.</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Chuyến đi</string>
<string name="widget_trip_description">Đếm ngược đến chuyến đi tiếp theo, trong chuyến đi thì hiện ngày và điểm tiếp theo.</string>
<string name="widget_today_name">Hôm nay</string>
<string name="widget_today_description">Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.</string>
<string name="widget_bookings_name">Đặt chỗ</string>
<string name="widget_bookings_description">Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.</string>
<string name="widget_todos_name">Việc cần làm</string>
<string name="widget_todos_description">Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.</string>
<string name="widget_packing_name">Hành lý</string>
<string name="widget_packing_description">Túi đã đầy đến đâu, cân nặng và những gì còn thiếu.</string>
<string name="widget_atlas_name">Bản đồ thế giới</string>
<string name="widget_atlas_description">Những quốc gia bạn đã đến, sáng lên trên bản đồ.</string>
<string name="widget_collab_name">Nhóm du lịch</string>
<string name="widget_collab_description">Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở.</string>
<string name="widget_intent_title">Chọn chuyến đi</string>
<string name="widget_intent_description">Chuyến đi mà tiện ích này hiển thị.</string>
<string name="widget_intent_trip">Chuyến đi</string>
<string name="widget_intent_auto">Tự động</string>
<string name="widget_intent_auto_hint">Cùng chuyến đi với bảng điều khiển</string>
<string name="share_title">Thêm vào TREK</string>
<string name="share_cancel">Hủy</string>
<string name="share_close">Đóng</string>
<string name="share_trip">Chuyến đi</string>
<string name="share_no_trips">Mở TREK một lần, các chuyến đi của bạn sẽ hiện ở đây.</string>
<string name="share_hint">TREK đọc đặt chỗ và thêm vào chuyến đi.</string>
<string name="share_later">Chọn sau</string>
<string name="share_add">Thêm</string>
<string name="share_add_to">Thêm vào %1$s</string>
<string name="share_added">Đã thêm</string>
<string name="share_reading">Đang đọc…</string>
<string name="share_too_large">Tệp này quá lớn để nhập.</string>
<string name="share_unsupported">TREK không thể nhập nội dung này.</string>
<string name="share_failed">Không thể chuyển sang TREK. Vui lòng thử lại.</string>
<string name="share_now">Đang diễn ra</string>
<string name="share_past">Đã qua</string>
<string name="share_tomorrow">Ngày mai</string>
<string name="share_in_days">Sau %1$d ngày</string>
<string name="share_kind_pass">Thẻ Wallet</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Email</string>
<string name="share_kind_page">Trang web</string>
<string name="share_kind_text">Văn bản</string>
<string name="share_kind_image">Ảnh</string>
<string name="share_kind_file">Tệp</string>
<string name="widget_empty">Mở TREK một lần, các chuyến đi của bạn sẽ hiện ở đây.</string>
<string name="push_channel_chat">Trò chuyện chuyến đi</string>
<string name="push_channel_trips">Cập nhật chuyến đi</string>
<string name="push_channel_invites">Lời mời và chia sẻ</string>
<string name="push_channel_name">Chung</string>
<string name="push_channel_tripday">Ngày đi</string>
<string name="push_fallback_body">Thông báo mới</string>
<string name="push_channel_tripday_description">Ngày của chuyến đi đang diễn ra và điểm dừng tiếp theo.</string>
</resources>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- A stop without a category colour in the travel-day progress bar. -->
<color name="tripday_segment">#4A7DDB</color>
<!-- The tint of TREK's notifications: the app's ink, readable on light and dark shades. -->
<color name="notification_accent">#4A7DDB</color>
</resources>
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Firebase project the app receives push through (liketrek push relay).
These are the strings the google-services Gradle plugin would generate from
google-services.json; Firebase reads them at start. None of them is a secret:
they identify the project, sending needs the relay's service account. -->
<resources>
<string name="google_app_id" translatable="false">1:1074454522965:android:3ff2fe270a27a73051b8dc</string>
<string name="gcm_defaultSenderId" translatable="false">1074454522965</string>
<string name="google_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyB2VYXmZ90BZuksHhHR_n2W8naNE_vJAJA</string>
<string name="project_id" translatable="false">trek-9392e</string>
<string name="google_storage_bucket" translatable="false">trek-9392e.firebasestorage.app</string>
</resources>
@@ -1,8 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name" translatable="false">TREK</string>
<string name="title_activity_main" translatable="false">TREK</string>
<string name="package_name" translatable="false">com.liketrek.trek</string>
<string name="custom_url_scheme" translatable="false">com.liketrek.trek</string>
<string name="push_fallback_title" translatable="false">TREK</string>
<string name="app_name">TREK</string>
<string name="title_activity_main">TREK</string>
<string name="package_name">com.liketrek.trek</string>
<string name="custom_url_scheme">com.liketrek.trek</string>
</resources>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by client/scripts/generate-android-strings.mjs from the iOS widget and share
sheet strings and scripts/android-strings.json. Edit those and run it again. -->
<resources>
<string name="widget_trip_name">Trip</string>
<string name="widget_trip_description">Counts down to your next trip, then shows the day and what comes next.</string>
<string name="widget_today_name">Today</string>
<string name="widget_today_description">Today\'s plan as a timeline, with the weather and what is next.</string>
<string name="widget_bookings_name">Bookings</string>
<string name="widget_bookings_description">Your next flights, trains, hotels and tables, and when they are due.</string>
<string name="widget_todos_name">To-dos</string>
<string name="widget_todos_description">What is still open for the trip, and how far the packing has come.</string>
<string name="widget_packing_name">Packing</string>
<string name="widget_packing_description">How full the bags are, their weight, and what is still missing.</string>
<string name="widget_atlas_name">World map</string>
<string name="widget_atlas_description">The countries you have been to, glowing on the map.</string>
<string name="widget_collab_name">Travel group</string>
<string name="widget_collab_description">Who comes along, the latest messages and the open poll.</string>
<string name="widget_intent_title">Choose trip</string>
<string name="widget_intent_description">The trip this widget shows.</string>
<string name="widget_intent_trip">Trip</string>
<string name="widget_intent_auto">Automatic</string>
<string name="widget_intent_auto_hint">Same trip as the dashboard</string>
<string name="share_title">Add to TREK</string>
<string name="share_cancel">Cancel</string>
<string name="share_close">Close</string>
<string name="share_trip">Trip</string>
<string name="share_no_trips">Open TREK once and your trips show up here.</string>
<string name="share_hint">TREK reads the booking and adds it to the trip.</string>
<string name="share_later">Choose later</string>
<string name="share_add">Add</string>
<string name="share_add_to">Add to %1$s</string>
<string name="share_added">Added</string>
<string name="share_reading">Reading…</string>
<string name="share_too_large">This file is too large to import.</string>
<string name="share_unsupported">TREK cannot import this.</string>
<string name="share_failed">This could not be handed to TREK. Please try again.</string>
<string name="share_now">Now</string>
<string name="share_past">Past</string>
<string name="share_tomorrow">Tomorrow</string>
<string name="share_in_days">In %1$d days</string>
<string name="share_kind_pass">Wallet pass</string>
<string name="share_kind_pdf">PDF</string>
<string name="share_kind_mail">Email</string>
<string name="share_kind_page">Web page</string>
<string name="share_kind_text">Text</string>
<string name="share_kind_image">Photo</string>
<string name="share_kind_file">File</string>
<string name="widget_empty">Open TREK once and your trips show up here.</string>
<string name="push_channel_chat">Trip chat</string>
<string name="push_channel_trips">Trip updates</string>
<string name="push_channel_invites">Invitations and shares</string>
<string name="push_channel_name">General</string>
<string name="push_channel_tripday">Travel day</string>
<string name="push_fallback_body">New notification</string>
<string name="push_channel_tripday_description">The day of a running trip and its next stop.</string>
</resources>
@@ -16,19 +16,6 @@
</style>
<!-- The app's own Compose screens; Compose paints the rest. -->
<style name="AppTheme.Native" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<!-- The share sheet: a see-through window the bottom sheet rises over. -->
<style name="AppTheme.Sheet" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<!-- Android 12+ draws its own launch screen: the app icon on this colour.
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The trips as direct share targets: the share sheet offers the trip shortcuts
TrekShortcuts publishes (category TRIP_SHARE) for these types, and a tap opens
the share sheet with that trip already picked. -->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<share-target android:targetClass="com.liketrek.trek.share.ShareActivity">
<data android:mimeType="application/pdf" />
<data android:mimeType="application/vnd.apple.pkpass" />
<data android:mimeType="message/rfc822" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/html" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
<category android:name="com.liketrek.trek.category.TRIP_SHARE" />
</share-target>
</shortcuts>
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The atlas widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_atlas_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:previewImage="@drawable/widget_preview_atlas" />
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The bookings widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_bookings_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewImage="@drawable/widget_preview_bookings" />
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The collab widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_collab_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewImage="@drawable/widget_preview_collab" />
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The packing widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_packing_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewImage="@drawable/widget_preview_packing" />
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The today widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_today_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewImage="@drawable/widget_preview_today" />
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The todos widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_todos_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewImage="@drawable/widget_preview_todos" />
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The trip widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_trip_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:previewImage="@drawable/widget_preview_trip" />
@@ -1,89 +0,0 @@
package com.liketrek.trek
import com.liketrek.trek.clientcert.ClientCertWatch
import com.liketrek.trek.clientcert.TrekClientIdentity
import com.liketrek.trek.clientcert.TrekClientIdentity.Failure
import java.util.Date
import javax.security.auth.x500.X500Principal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/**
* Reading a client certificate file (TrekClientIdentity.read) and the rule for
* which server sees it. The files in test/resources/clientcert are test-only:
* a throwaway CA, password "trek-test". valid.p12 has OpenSSL 3's default
* encryption (AES-256, PBKDF2), valid-legacy.p12 the older RC2/3DES one that
* many routers and NAS systems still export.
*/
class ClientIdentityTest {
private val password = "trek-test".toCharArray()
private val inside = Date(1_800_000_000_000L) // 2027-01-15, inside every valid test certificate
private fun file(name: String): ByteArray =
requireNotNull(javaClass.classLoader?.getResourceAsStream("clientcert/$name")) { name }.use { it.readBytes() }
private fun failure(name: String, pass: CharArray = password, now: Date = inside): Failure {
try {
TrekClientIdentity.read(file(name), pass, now)
} catch (e: TrekClientIdentity.ImportException) {
return e.reason
}
fail("$name opened")
throw AssertionError()
}
@Test
fun opensModernAndLegacyFiles() {
for (name in listOf("valid.p12", "valid-legacy.p12")) {
val identity = TrekClientIdentity.read(file(name), password, inside)
assertEquals("trek-test-valid", TrekClientIdentity.commonName(identity.chain[0].subjectX500Principal))
assertEquals("TREK mTLS Test CA", TrekClientIdentity.commonName(identity.chain[0].issuerX500Principal))
assertEquals("RSA", identity.key.algorithm)
}
}
@Test
fun namesWhatIsWrongWithAFile() {
assertEquals(Failure.PASSWORD, failure("valid.p12", "wrong".toCharArray()))
assertEquals(Failure.PASSWORD, failure("valid-legacy.p12", "wrong".toCharArray()))
assertEquals(Failure.EXPIRED, failure("expired.p12"))
assertEquals(Failure.NOT_YET_VALID, failure("future.p12"))
assertEquals(Failure.NO_KEY, failure("cert-only.p12"))
try {
TrekClientIdentity.read("not a certificate".toByteArray(), password, inside)
fail("text opened")
} catch (e: TrekClientIdentity.ImportException) {
assertEquals(Failure.FORMAT, e.reason)
}
}
@Test
fun showsTheCertificateOnlyToItsHostAndPort() {
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "Trek.Example.com", -1))
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 443))
assertTrue(TrekClientIdentity.matches("nas.local", 8443, "nas.local", 8443))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "auth.example.com", 443))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 8443))
assertFalse(TrekClientIdentity.matches("nas.local", 8443, "nas.local", -1))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, null, 443))
}
@Test
fun leavesForTheAddressScreenOnlyWhenTheCertificateIsWhatFails() {
assertTrue(ClientCertWatch.refused(true, 0)) // handshake broken off: nginx, Caddy, Traefik
assertTrue(ClientCertWatch.refused(true, 403)) // Cloudflare's block page
assertTrue(ClientCertWatch.refused(true, 400)) // nginx without a valid certificate
assertFalse(ClientCertWatch.refused(true, 200)) // asks, but lets everyone in
assertFalse(ClientCertWatch.refused(true, 502)) // TREK restarting behind the proxy
assertFalse(ClientCertWatch.refused(false, 0)) // simply offline
}
@Test
fun readsTheCommonNameOrFallsBackToTheWholeName() {
assertEquals("Phone, home", TrekClientIdentity.commonName(X500Principal("CN=Phone\\, home,O=Family")))
assertEquals("O=Family", TrekClientIdentity.commonName(X500Principal("O=Family")))
}
}
@@ -1,193 +0,0 @@
package com.liketrek.trek
import android.view.KeyEvent
import com.android.billingclient.api.Purchase
import com.liketrek.trek.billing.TrekBilling
import com.liketrek.trek.screen.TrekKeyCommands
import com.liketrek.trek.screen.TrekWindowLayout
import com.liketrek.trek.share.ShareHandoff
import com.liketrek.trek.share.ShareItem
import com.liketrek.trek.share.ShareLoader
import com.liketrek.trek.tiles.TrekTileStore
import java.time.LocalDate
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** The native side's own decisions, apart from the views. */
class NativeLogicTest {
@Test
fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() {
val server = "https://trek.example.com"
// Authelia in front of the page, its check let through on /api.
assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false))
assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false))
// A link the user follows, a frame, or anything after the server's page has shown: the browser, as before.
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false))
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false))
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true))
// The server's own host, another scheme, or a step down to plain http.
assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false))
assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false))
assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false))
assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false))
assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false))
assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard"))
assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/"))
}
@Test
fun pushesLandInTheChannelOfTheirEvent() {
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
assertEquals(TrekPushService.CHANNEL_TRIPS, TrekPushService.channelFor("booking_change"))
assertEquals(TrekPushService.CHANNEL_TRIPS, TrekPushService.channelFor("todo_due"))
assertEquals(TrekPushService.CHANNEL_INVITES, TrekPushService.channelFor("trip_invite"))
assertEquals(TrekPushService.CHANNEL, TrekPushService.channelFor("version_available"))
// A server too old to name the event: the general channel.
assertEquals(TrekPushService.CHANNEL, TrekPushService.channelFor(null))
}
@Test
fun sharedFilesAreSortedByWhatTheyAre() {
assertEquals(ShareItem.Kind.PASS to "application/vnd.apple.pkpass", ShareLoader.classify("Boarding.pkpass", "application/octet-stream"))
assertEquals(ShareItem.Kind.PDF to "application/pdf", ShareLoader.classify("ticket", "application/pdf"))
assertEquals(ShareItem.Kind.MAIL to "message/rfc822", ShareLoader.classify("Booking.eml", "application/octet-stream"))
assertEquals(ShareItem.Kind.CALENDAR to "text/calendar", ShareLoader.classify("invite.ics", ""))
assertEquals(ShareItem.Kind.IMAGE to "image/png", ShareLoader.classify("shot.png", "image/png"))
assertEquals(ShareItem.Kind.FILE to "application/zip", ShareLoader.classify("a.zip", "application/zip"))
}
@Test
fun aSharedLinkIsFetchedOnlyWhenTheTextIsLittleMore() {
assertEquals("https://example.com/booking/1", ShareLoader.firstLink("Your booking: https://example.com/booking/1."))
assertNull(ShareLoader.firstLink("no link here"))
assertNull(ShareLoader.firstLink("x".repeat(400) + " https://example.com/a"))
assertEquals("Hotel Kanra", ShareLoader.pageTitle("<html><head><TITLE> Hotel Kanra </TITLE></head></html>"))
assertEquals("hotel-kanra-kyoto", ShareLoader.slug("Hotel Kanra, Kyoto!"))
assertEquals("page", ShareLoader.slug("!!!"))
}
@Test
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
assertEquals("text/uri-list", item.mimeType)
assertEquals("https://www.instagram.com/p/abc", item.url)
assertEquals("https://www.instagram.com/p/abc", String(item.data))
assertEquals("www.instagram.com", item.title)
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
}
@Test
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
val today = LocalDate.of(2026, 11, 7)
fun trip(id: Long, start: String, end: String) =
ShareHandoff.Trip(id, "T$id", LocalDate.parse(start), LocalDate.parse(end), null, null)
val past = (1L..8L).map { trip(100 + it, "2026-0$it-01", "2026-0$it-05") }
val ordered = ShareHandoff.order(
listOf(trip(1, "2026-12-01", "2026-12-05"), trip(2, "2026-11-05", "2026-11-09")) + past,
today,
)
assertEquals(listOf(2L, 1L), ordered.take(2).map { it.id })
assertEquals(8, ordered.size)
assertEquals(108L, ordered[2].id)
}
@Test
fun keyCommandsFollowTheUi() {
TrekKeyCommands.set(
JSONArray(
"""[{"id":"search","title":"Search","input":"k","modifiers":["command"]},
{"id":"back","title":"Back","input":"[","modifiers":["command"]},
{"id":"close","title":"Close","input":"escape","modifiers":[]},
{"title":"no id","input":"x"}]""",
),
)
assertEquals(3, TrekKeyCommands.current.size)
assertEquals("search", TrekKeyCommands.match(KeyEvent.KEYCODE_K, KeyEvent.META_CTRL_ON)?.id)
// A Mac keyboard's Command key is Meta here, and counts as Ctrl.
assertEquals("search", TrekKeyCommands.match(KeyEvent.KEYCODE_K, KeyEvent.META_META_ON)?.id)
assertNull(TrekKeyCommands.match(KeyEvent.KEYCODE_K, 0))
assertNull(TrekKeyCommands.match(KeyEvent.KEYCODE_K, KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON))
assertEquals("back", TrekKeyCommands.match(KeyEvent.KEYCODE_LEFT_BRACKET, KeyEvent.META_CTRL_ON)?.id)
assertEquals("close", TrekKeyCommands.match(KeyEvent.KEYCODE_ESCAPE, 0)?.id)
TrekKeyCommands.set(null)
assertEquals(0, TrekKeyCommands.current.size)
}
@Test
fun windowSizeClassesUseAndroidsBreakpoints() {
assertEquals("compact", TrekWindowLayout.sizeClass(411f, 600f, 840f))
assertEquals("medium", TrekWindowLayout.sizeClass(673f, 600f, 840f))
assertEquals("expanded", TrekWindowLayout.sizeClass(1280f, 600f, 840f))
}
@Test
fun aRestoreTakesTheNewestActiveCloudSubscription() {
fun purchase(product: String, time: Long, state: Int) = Purchase(
JSONObject()
.put("productIds", JSONArray().put(product))
.put("purchaseTime", time)
.put("purchaseState", state)
.put("purchaseToken", "token-$product-$time")
.toString(),
"sig",
)
val found = TrekBilling.newest(
listOf(
purchase("cloud.start.monthly", 1000, 0),
purchase("cloud.plus.yearly", 2000, 0),
purchase("cloud.pro.yearly", 3000, 4),
purchase("other.thing", 4000, 0),
),
"cloud.",
)
assertEquals("token-cloud.plus.yearly-2000", found?.purchaseToken)
assertNull(TrekBilling.newest(emptyList(), "cloud."))
}
@Test
fun mapboxRequestsKeepTheWebViewsHeaders() {
val forwarded = TrekTileStore.forwardedHeaders(
mapOf(
"Referer" to "https://trek.example.com/",
"Origin" to "https://trek.example.com",
"User-Agent" to "Mozilla/5.0 (Linux; Android 16; wv)",
"Accept" to "application/x-protobuf",
"Accept-Encoding" to "gzip",
"Connection" to "keep-alive",
"Host" to "api.mapbox.com",
),
)
// A URL-restricted token is checked against the Referer, so it has to go along.
assertEquals("https://trek.example.com/", forwarded["Referer"])
assertEquals("https://trek.example.com", forwarded["Origin"])
assertEquals("Mozilla/5.0 (Linux; Android 16; wv)", forwarded["User-Agent"])
assertEquals("application/x-protobuf", forwarded["Accept"])
assertNull(forwarded["Accept-Encoding"])
assertNull(forwarded["Connection"])
assertNull(forwarded["Host"])
assertEquals(emptyMap<String, String>(), TrekTileStore.forwardedHeaders(null))
}
@Test
fun aPaymentThatClearedLaterIsTheUndeliveredOne() {
fun purchase(product: String, time: Long, acknowledged: Boolean) = Purchase(
JSONObject()
.put("productIds", JSONArray().put(product))
.put("purchaseTime", time)
.put("purchaseState", 0)
.put("acknowledged", acknowledged)
.put("purchaseToken", "token-$time")
.toString(),
"sig",
)
val renewed = purchase("cloud.plus.yearly", 3000, acknowledged = true)
val cleared = purchase("cloud.start.monthly", 2000, acknowledged = false)
assertEquals("token-2000", TrekBilling.undelivered(listOf(renewed, cleared), "cloud.")?.purchaseToken)
assertNull(TrekBilling.undelivered(listOf(renewed), "cloud."))
}
}

Some files were not shown because too many files have changed in this diff Show More