Compare commits
264 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ca59a4ba1 | |||
| 5420077b98 | |||
| 5cfc7a602a | |||
| b48e344749 | |||
| e58a880f34 | |||
| 17ff7f6bd8 | |||
| f03bb97d50 | |||
| ed0142be7d | |||
| 6e9f89a25f | |||
| a3c7c3d76a | |||
| c043ca4bba | |||
| 004c6212c0 | |||
| 07448e662c | |||
| d3bdc6de62 | |||
| a9bfcd0fd5 | |||
| 12caa7fbde | |||
| 1cbd4acf01 | |||
| 687e477ca5 | |||
| 3a66f2a8da | |||
| 7f52e424e8 | |||
| 2479e5661b | |||
| bc2fb1ce2d | |||
| ffa07b3d05 | |||
| 306753d6a1 | |||
| 80c04df4fc | |||
| c9a91bbdad | |||
| d06168d94a | |||
| d9baaa8957 | |||
| fede671f3b | |||
| b3113e66b9 | |||
| 286b0b09ec | |||
| c0b39abe18 | |||
| 6e3a6386b8 | |||
| 5e7d778740 | |||
| 3be87343b8 | |||
| 517efabbca | |||
| 1866dca818 | |||
| 1400b5d308 | |||
| b529cf4e57 | |||
| 6cc3d235b1 | |||
| a5b1156612 | |||
| c6ffd48e26 | |||
| afb6eae4c8 | |||
| cd1f39377d | |||
| 3a99e8576d | |||
| 36c8c5bc48 | |||
| 4ba7db90e6 | |||
| ab6cd7009e | |||
| b16ac683cf | |||
| 95d62b7a02 | |||
| e444e855c0 | |||
| 4ab42c06c9 | |||
| 9f6ab11839 | |||
| 5f711d5374 | |||
| 4d550112a1 | |||
| 26dc88181d | |||
| 3d4f8bfe12 | |||
| 5fb1838c7a | |||
| 38e5d76ad2 | |||
| a9af19f02a | |||
| 305b00ae01 | |||
| ed6724035e | |||
| eea83bb1c9 | |||
| 06c03cbded | |||
| b73f749880 | |||
| 53c18b861a | |||
| 9b1bf89382 | |||
| c7f144d3ff | |||
| cb41f08199 | |||
| eafb30555c | |||
| 4f1f48eec5 | |||
| 42341c2c76 | |||
| 6a60fc183a | |||
| 236b4c34ac | |||
| c6cb3dc64a | |||
| 3fc7487b58 | |||
| ac055d531f | |||
| 0ef3cbf520 | |||
| 3458238db0 | |||
| afaacec61a | |||
| 5f4fb5dae7 | |||
| 788b1f63d0 | |||
| bfb1b04f8d | |||
| a77ec6e225 | |||
| 2851eb7afe | |||
| 6c7605b4a6 | |||
| 3b286d04c3 | |||
| 21afe89b13 | |||
| 060a43ba64 | |||
| dedbf16f03 | |||
| 796f39fe1f | |||
| ee9cbce23d | |||
| 82fcdcd0fc | |||
| 3b7cdf516a | |||
| 41752887dc | |||
| b9d0d3397e | |||
| 2a2dfec247 | |||
| 21e07e20a1 | |||
| 322d5d5209 | |||
| 0346f9c13d | |||
| dfd87d413e | |||
| 27aab5c783 | |||
| a7cc1b9e57 | |||
| 8955790f17 | |||
| 61d925f3dd | |||
| bf00412863 | |||
| 2a6c771ad9 | |||
| 641b1882b5 | |||
| 5346182807 | |||
| 4dfccaa2a4 | |||
| 18a16d2aa2 | |||
| b7a91fc84e | |||
| bb725ac8d8 | |||
| b644235eb6 | |||
| d259ecb11c | |||
| 5d05363f36 | |||
| 6906f80ff8 | |||
| 7083667cbf | |||
| 0b7cee0b0d | |||
| 36ed0e0d23 | |||
| b1c0353548 | |||
| c204a25a9c | |||
| b008e010a5 | |||
| 02b0454b3d | |||
| 9c1e63e64b | |||
| dacc413c34 | |||
| b8f6a99895 | |||
| db82e04b49 | |||
| 3abc9eacf4 | |||
| 3a0edafdf0 | |||
| 35d2b19f4c | |||
| 6b5b2f614d | |||
| 92f10531d0 | |||
| d5ba4439f2 | |||
| 78a28e2747 | |||
| 71e3ddabed | |||
| e59af6dcb3 | |||
| 9755330113 | |||
| 5ca44453ae | |||
| 4461899910 | |||
| ec22642f38 | |||
| 46f4cab1b7 | |||
| c1c71a12cf | |||
| 99a8701bd4 | |||
| 924836be31 | |||
| da05ba1121 | |||
| 1e4f1922e8 | |||
| 3beb5c4223 | |||
| d41a4f0827 | |||
| 41c713fd9a | |||
| e1a08e82b9 | |||
| 0557c7290b | |||
| a2aeeb32c8 | |||
| 640998a4ef | |||
| ac7dde7d93 | |||
| 6e1c9e8e9f | |||
| 354e30a075 | |||
| 37a4e84360 | |||
| 794da3be72 | |||
| 7b8bd4b166 | |||
| 792e55b2ca | |||
| 5462bce9c3 | |||
| ede54d10cd | |||
| e7a99db03b | |||
| e5a119b6bd | |||
| 315db3fa5a | |||
| 19ef38a377 | |||
| 7797a9a562 | |||
| 55e4bfa11e | |||
| ad0fa41bb5 | |||
| 7c91a609da | |||
| 545eb650ca | |||
| 43f86f4559 | |||
| 25f2cd8192 | |||
| 7ae9198a43 | |||
| 8f3a8bd670 | |||
| e0c3642874 | |||
| 25091dbd60 | |||
| fe7847fc08 | |||
| 77b9252577 | |||
| 3364c0d131 | |||
| deabaf7398 | |||
| 9b780d6b7e | |||
| 93727084ec | |||
| ed178f3b5e | |||
| f46f6f38fc | |||
| ae082c1fd0 | |||
| 6b8eb939c3 | |||
| 89f7bd2e2f | |||
| 2fe487154f | |||
| 71d014af95 | |||
| b1037dec62 | |||
| 3466556849 | |||
| b7951baf23 | |||
| f81fe8cb17 | |||
| 7e8e342e29 | |||
| 445fb173fb | |||
| b317877c41 | |||
| 27c19b5ff5 | |||
| ffd848e5dc | |||
| 45361c276c | |||
| 1b24e3dcef | |||
| 69d88ffefd | |||
| 92fc8ef28f | |||
| 2aa25e3fe7 | |||
| f6bcdb8e6f | |||
| 867a52bc0d | |||
| ec87f280c3 | |||
| 6e362de3da | |||
| 654900bc50 | |||
| 75d0a6181b | |||
| 6de15a92a4 | |||
| b15dcd9730 | |||
| 821f23aed4 | |||
| 79420cd945 | |||
| e3541535e2 | |||
| 431cc226e3 | |||
| 6835fd7bf3 | |||
| 3c4c880c31 | |||
| f35b189ca2 | |||
| c6f91716a4 | |||
| ad69bbf4a9 | |||
| 30158cddc3 | |||
| 825469a7f2 | |||
| 56f9a92054 | |||
| 017f229cfc | |||
| dec6babe1d | |||
| d859e23879 | |||
| f92aea80a7 | |||
| 7b29a5d34e | |||
| c7a369a0ca | |||
| 117af0f9b1 | |||
| 496c85e6e5 | |||
| 1c15c33cf9 | |||
| 50bd1bcbc1 | |||
| d2ae9a87c4 | |||
| c5cb39c27b | |||
| 6d093d1102 | |||
| 01ddcc63bb | |||
| b07b38b5b7 | |||
| 83f0bd2ead | |||
| 7293ee1174 | |||
| 25351c3628 | |||
| d18fb7f63d | |||
| 1054f735ad | |||
| 954d419671 | |||
| 2077b9e375 | |||
| 5135052446 | |||
| f9e1dfe804 | |||
| bd42661a62 | |||
| 3b4d220936 | |||
| e3ac025a59 | |||
| c04864f693 | |||
| a9b05bba15 | |||
| 6ce19eb4a8 | |||
| 52ddf9b4f7 | |||
| 61a7b5bceb | |||
| 70cfe9bfb0 | |||
| b63f1f01b5 | |||
| e9d9690844 | |||
| b1a31d4d68 | |||
| 5cb33b2858 | |||
| 679f394b51 | |||
| 91b5b06a7f |
@@ -140,7 +140,9 @@ jobs:
|
||||
set -eu
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
# 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="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
# 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'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
environment: store
|
||||
@@ -46,7 +49,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -148,44 +151,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: Archive
|
||||
working-directory: client/ios/App
|
||||
- name: Distribution certificate
|
||||
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"
|
||||
|
||||
# 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
|
||||
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 }}
|
||||
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"
|
||||
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
|
||||
|
||||
# ExportOptions.plist says destination=upload, so this step sends the build
|
||||
# to App Store Connect, where it shows up in TestFlight after processing.
|
||||
- name: Export and upload to App Store Connect
|
||||
working-directory: client/ios/App
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: >-
|
||||
xcodebuild -exportArchive
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
-exportOptionsPlist ../ExportOptions.plist
|
||||
-exportPath "$RUNNER_TEMP/export"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Remove the API key
|
||||
- name: Remove secrets
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
@@ -17,6 +17,7 @@ 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'
|
||||
@@ -59,7 +60,7 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -80,7 +81,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 --no-daemon
|
||||
run: ./gradlew assembleDebug assembleRelease lintDebug testDebugUnitTest --no-daemon
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
@@ -103,7 +104,7 @@ jobs:
|
||||
xcodebuild -version
|
||||
|
||||
- name: Check the property lists
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -120,6 +121,23 @@ 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: |
|
||||
@@ -127,6 +145,133 @@ 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
|
||||
|
||||
@@ -1236,10 +1236,12 @@ jobs:
|
||||
bin="$RUNNER_TEMP/bin"
|
||||
mkdir -p "$bin"
|
||||
cd "$RUNNER_TEMP"
|
||||
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
|
||||
# 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"
|
||||
echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
|
||||
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
|
||||
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
|
||||
fetch 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"
|
||||
|
||||
@@ -70,7 +70,10 @@ 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,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.1.0
|
||||
version: 5.1.2
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.1.0"
|
||||
appVersion: "5.1.2"
|
||||
|
||||
@@ -127,6 +127,9 @@ 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 }}
|
||||
|
||||
@@ -159,6 +159,10 @@ 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
|
||||
|
||||
@@ -106,3 +106,6 @@ 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/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
android {
|
||||
namespace = "com.liketrek.trek"
|
||||
@@ -32,6 +34,25 @@ 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')) {
|
||||
@@ -56,11 +77,40 @@ 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,7 +9,9 @@ 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')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,125 @@
|
||||
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())
|
||||
|
||||
}
|
||||
@@ -9,21 +9,33 @@
|
||||
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|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout|uiMode|navigation|density|fontScale|touchscreen|colorMode|fontWeightAdjustment"
|
||||
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).
|
||||
@@ -50,6 +62,131 @@
|
||||
|
||||
</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"
|
||||
@@ -76,5 +213,12 @@
|
||||
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,14 +2,25 @@ 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
|
||||
@@ -25,7 +36,34 @@ 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));
|
||||
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);
|
||||
}
|
||||
|
||||
private boolean leavingServer;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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,6 +10,10 @@ 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;
|
||||
|
||||
@@ -18,26 +22,80 @@ 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();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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,21 +1,44 @@
|
||||
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")
|
||||
@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"),
|
||||
}
|
||||
)
|
||||
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;
|
||||
|
||||
@@ -24,6 +47,226 @@ 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());
|
||||
@@ -40,11 +283,26 @@ public class TrekShellPlugin extends Plugin {
|
||||
call.reject("A http(s) URL is required");
|
||||
return;
|
||||
}
|
||||
TrekServerStore.setUrl(getContext(), url);
|
||||
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);
|
||||
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);
|
||||
@@ -60,6 +318,8 @@ 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());
|
||||
}
|
||||
@@ -81,12 +341,45 @@ 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.
|
||||
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -104,6 +397,7 @@ 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(() -> {
|
||||
@@ -146,4 +440,370 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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,11 +1,17 @@
|
||||
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
|
||||
@@ -18,10 +24,90 @@ import com.getcapacitor.BridgeWebViewClient;
|
||||
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) {
|
||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
||||
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
|
||||
@@ -36,14 +122,22 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||
int status = errorResponse.getStatusCode();
|
||||
if (!request.isForMainFrame()) {
|
||||
super.onReceivedHttpError(view, request, errorResponse);
|
||||
} else if (errorResponse.getStatusCode() >= 500) {
|
||||
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,13 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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>
|
||||
@@ -0,0 +1 @@
|
||||
unqualifiedResLocale=en-US
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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,7 +1,8 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<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>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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,6 +16,19 @@
|
||||
</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.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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" />
|
||||
@@ -0,0 +1,89 @@
|
||||
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")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.liketrek.trek;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.security.spec.ECPrivateKeySpec;
|
||||
import java.util.Base64;
|
||||
import org.junit.Test;
|
||||
|
||||
/** The decryption against the worked example in RFC 8291, section 5. */
|
||||
public class TrekWebPushTest {
|
||||
|
||||
private static byte[] b64(String value) {
|
||||
return Base64.getUrlDecoder().decode(value);
|
||||
}
|
||||
|
||||
private static TrekPushKeys rfcKeys() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
ECPublicKey anyKey = (ECPublicKey) generator.generateKeyPair().getPublic();
|
||||
BigInteger d = new BigInteger(1, b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"));
|
||||
ECPrivateKey privateKey = (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, anyKey.getParams()));
|
||||
return new TrekPushKeys(
|
||||
privateKey,
|
||||
b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"),
|
||||
b64("BTBZMqHH6r4Tts7J_aSIgg")
|
||||
);
|
||||
}
|
||||
|
||||
private static final String RECORD =
|
||||
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
|
||||
|
||||
@Test
|
||||
public void decryptsTheRfcExample() throws Exception {
|
||||
byte[] plain = TrekWebPush.decrypt(b64(RECORD), rfcKeys());
|
||||
assertEquals("When I grow up, I want to be a watermelon", new String(plain, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesATamperedRecord() throws Exception {
|
||||
byte[] record = b64(RECORD);
|
||||
record[record.length - 1] ^= 1;
|
||||
assertThrows(Exception.class, () -> TrekWebPush.decrypt(record, rfcKeys()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writesThePublicKeyAsAFixedLengthPoint() throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
|
||||
generator.initialize(new ECGenParameterSpec("secp256r1"));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
byte[] point = TrekPushKeys.uncompressed((ECPublicKey) generator.generateKeyPair().getPublic());
|
||||
assertEquals(65, point.length);
|
||||
assertEquals(4, point[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||