mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e525e0b838 | |||
| 2d95bf6744 | |||
| 41134cc455 | |||
| 27f12c2245 | |||
| 4796615439 | |||
| 154563f34d | |||
| 3365511b10 | |||
| c2c0535d52 | |||
| d6799d1205 | |||
| 9cbf31da27 | |||
| 71cc97822a | |||
| c2ba78253d | |||
| 75aeba29ed | |||
| bd58f17ec0 | |||
| 721a610a70 | |||
| eda27f1e59 | |||
| a08d4b944e | |||
| 5137bcb86a | |||
| 7861006bf5 | |||
| 5f6594f697 | |||
| fcbb538c24 | |||
| 43a8984e4c | |||
| e958a9a2bb | |||
| 986dabf11f | |||
| 318e71aa0c | |||
| 308daec14e | |||
| 3f68c8a50e | |||
| 74af74fb5e | |||
| 924b4e0787 | |||
| d5d9428cf2 | |||
| b9231ffb53 | |||
| 489ba93c0c | |||
| 2a448722c6 | |||
| cd53d0a895 | |||
| db6bb911c2 | |||
| 69552ffeef | |||
| 1f710bd217 | |||
| 11e4fe6ae3 | |||
| 3e98938c95 | |||
| 7283b75cac | |||
| cae04e83dd | |||
| 72e8b1f8d9 | |||
| 73ac4b29ad | |||
| 208af03212 | |||
| 8401428ca7 | |||
| 12406bbbf4 | |||
| 12ae32b69e | |||
| 5176dc29d6 | |||
| 0eac3a9f49 | |||
| 64b86c2403 | |||
| 66d85729ff | |||
| e7bff6681e | |||
| c7cfa5a64f | |||
| 72fc61a016 | |||
| a70eea5bf2 | |||
| c9c419b4ad | |||
| 6759277c3b | |||
| 10d5f3ed46 | |||
| 2934319722 | |||
| d4c81bc5f2 | |||
| 35e0613986 | |||
| ccfa92f650 | |||
| fdb2095ced | |||
| d6a6b49b6b | |||
| 0855bafca7 | |||
| a39f3fe40c | |||
| f13a0aa6ee | |||
| 10d1a61614 | |||
| 2bcfc1bfcb | |||
| b6e3c9bf39 | |||
| 736dc5dda9 | |||
| ae26a6c61c | |||
| 8979bd1fcd | |||
| 74d243d8e0 | |||
| 56b9bac031 | |||
| 4983b3407c | |||
| ed1728feec | |||
| 11472c05ff | |||
| f2ac12ca4f | |||
| b53130ca0d | |||
| a3a4f7cf79 | |||
| ac7094df05 | |||
| 80130f7946 | |||
| fc648bc7e4 | |||
| 9005d6b333 | |||
| 52f0fb882f | |||
| b8ec428ad1 | |||
| a1d3ffc2ce | |||
| 46b9df54bf | |||
| 3c0c3b3f6e | |||
| 5395d53332 | |||
| ec08032e98 | |||
| cc5eca5553 | |||
| 1323183cce | |||
| 009be05d22 | |||
| 860ffc1dee | |||
| c515f95d17 | |||
| 855bf8610a | |||
| 742edc4a79 | |||
| 1975a23aab | |||
| 80c8d9c602 | |||
| 948c9ef57b | |||
| bac968c0bf | |||
| 520562ef86 | |||
| 001253c39b | |||
| 5345b50d17 | |||
| a3622e91b6 | |||
| be305c3daf | |||
| ec78cfbdd0 | |||
| 8023be452a | |||
| 5112c96c05 | |||
| 04b1fb72bb | |||
| a9d11ccb2e | |||
| 848e3f90e1 | |||
| e83b6741ad | |||
| 0a3d93b59b | |||
| 102e2141ec | |||
| 768fd6f439 | |||
| 231a610466 | |||
| 7db5ec0721 | |||
| a2b9d96e86 | |||
| e0022c12c4 | |||
| 66b8f2c906 | |||
| 6fbe10fe63 | |||
| 278871a31e | |||
| bdcef33a3e | |||
| 8e8cff8105 | |||
| 6b086eaed5 | |||
| 0ac06555cb | |||
| 1b87c62847 | |||
| 98eae61b90 | |||
| 19956f2742 | |||
| 7506eaa4aa | |||
| 57c9c9d5f2 | |||
| 0b3d2873c2 | |||
| 1454b58ea1 | |||
| 2ab4459bc3 | |||
| 6ebb92af6c | |||
| 3ab4927cb7 | |||
| 6a0ae482d7 | |||
| 20eeaeb61f | |||
| 5cbe4e2fa3 | |||
| b124e1bc9c | |||
| 08034c4402 | |||
| 0577a9ccb9 | |||
| 2f03554a60 | |||
| 28079197e7 | |||
| 0b46b89b62 | |||
| 05caf67bc4 | |||
| 6d3c400e4a | |||
| 9932288b04 | |||
| 56b13541bd | |||
| d568e20f02 | |||
| f03d59bc22 | |||
| 0833ca28c2 | |||
| 3ed5f21a48 | |||
| 2eb6b79bea | |||
| 8dd2e9716d | |||
| 1cdde6e9a4 | |||
| 034cfb539a | |||
| 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 |
@@ -175,7 +175,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -236,7 +236,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -403,7 +403,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@@ -111,19 +111,21 @@ jobs:
|
|||||||
|
|
||||||
ios:
|
ios:
|
||||||
if: github.repository == 'liketrek/TREK'
|
if: github.repository == 'liketrek/TREK'
|
||||||
runs-on: macos-15
|
runs-on: xcode-27
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
environment: store
|
environment: store
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# App Store uploads must be built with Xcode 26; the image's default is older.
|
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
|
||||||
- name: Select Xcode 26
|
# runs letterboxed on the inner display of an iPhone Duo.
|
||||||
|
- name: Select Xcode 27.1
|
||||||
run: |
|
run: |
|
||||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
XCODE=/Applications/Xcode_27.1.app
|
||||||
test -n "$XCODE"
|
test -d "$XCODE"
|
||||||
sudo xcode-select -s "$XCODE"
|
sudo xcode-select -s "$XCODE"
|
||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
|
xcodebuild -showsdks | grep iphoneos27.1
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- uses: actions/setup-java@v5
|
- uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
@@ -90,18 +90,20 @@ jobs:
|
|||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
ios:
|
ios:
|
||||||
runs-on: macos-15
|
runs-on: xcode-27
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# App Store uploads must be built with Xcode 26; the image's default is older.
|
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
|
||||||
- name: Select Xcode 26
|
# runs letterboxed on the inner display of an iPhone Duo.
|
||||||
|
- name: Select Xcode 27.1
|
||||||
run: |
|
run: |
|
||||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
XCODE=/Applications/Xcode_27.1.app
|
||||||
test -n "$XCODE"
|
test -d "$XCODE"
|
||||||
sudo xcode-select -s "$XCODE"
|
sudo xcode-select -s "$XCODE"
|
||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
|
xcodebuild -showsdks | grep iphoneos27.1
|
||||||
|
|
||||||
- name: Check the property lists
|
- name: Check the property lists
|
||||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
|
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist 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
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
load: true
|
load: true
|
||||||
tags: trek:scan
|
tags: trek:scan
|
||||||
|
|
||||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ jobs:
|
|||||||
path: plugin-sdk/coverage/
|
path: plugin-sdk/coverage/
|
||||||
|
|
||||||
- name: Scan
|
- name: Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
|
uses: SonarSource/sonarqube-scan-action@e805d875275f728fac1885b413934be6d7c63f65 # v8.4.1
|
||||||
env:
|
env:
|
||||||
# SonarQube Cloud is the scan action's default host, so no
|
# SonarQube Cloud is the scan action's default host, so no
|
||||||
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
||||||
@@ -1276,6 +1276,10 @@ jobs:
|
|||||||
--set httpRoute.enabled=true \
|
--set httpRoute.enabled=true \
|
||||||
--set 'httpRoute.parentRefs[0].name=gateway' \
|
--set 'httpRoute.parentRefs[0].name=gateway' \
|
||||||
> "$out/http-route.yaml"
|
> "$out/http-route.yaml"
|
||||||
|
helm template trek charts/trek \
|
||||||
|
--set 'persistence.data.accessModes[0]=ReadWriteMany' \
|
||||||
|
--set 'persistence.uploads.accessModes[0]=ReadWriteMany' \
|
||||||
|
> "$out/access-modes.yaml"
|
||||||
helm template trek charts/trek \
|
helm template trek charts/trek \
|
||||||
--set podSecurityContext.runAsUser=1000 \
|
--set podSecurityContext.runAsUser=1000 \
|
||||||
--set podSecurityContext.runAsGroup=1000 \
|
--set podSecurityContext.runAsGroup=1000 \
|
||||||
|
|||||||
@@ -20,7 +20,23 @@ geoBoundaries is licensed under **CC BY 4.0**
|
|||||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
||||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
||||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
||||||
(gbOpen) release.
|
(gbOpen) release, except for Ireland (below).
|
||||||
|
|
||||||
|
## Tailte Éireann — counties of Ireland
|
||||||
|
|
||||||
|
Ireland's regions in the same files (`admin1/IE.geojson.gz`, and its features in
|
||||||
|
`admin1.geojson.gz`) are the 26 counties from Tailte Éireann's *Counties - National
|
||||||
|
Statutory Boundaries - 2019 - Generalised 20m*, because geoBoundaries has no county
|
||||||
|
layer for Ireland.
|
||||||
|
|
||||||
|
Contains Tailte Éireann data, © Tailte Éireann, licensed under **CC BY 4.0**
|
||||||
|
(https://creativecommons.org/licenses/by/4.0/). Source:
|
||||||
|
https://data-osi.opendata.arcgis.com/
|
||||||
|
|
||||||
|
The outlines are simplified, coordinate-quantized and re-tagged with ISO 3166-2:IE
|
||||||
|
codes by `server/scripts/build-atlas-geo.mjs`. Tailte Éireann publishes the
|
||||||
|
generalised boundaries for reference only; they may not match the statutory
|
||||||
|
boundaries exactly.
|
||||||
|
|
||||||
## OpenStreetMap — geocoding, place details & descriptions
|
## OpenStreetMap — geocoding, place details & descriptions
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
|||||||
#### 🗺️ Atlas, Vacay and Collections
|
#### 🗺️ Atlas, Vacay and Collections
|
||||||
|
|
||||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
- **Vacay**: leave calendar with half days, public holidays from [nagerholidays.com](https://nagerholidays.com), school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||||
|
|
||||||
@@ -276,6 +276,8 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
|||||||
|
|
||||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||||
|
|
||||||
|
**TrueNAS SCALE:** Install TREK directly from the Community catalog. See the [TrueNAS SCALE installation guide](https://github.com/liketrek/TREK/wiki/Install-TrueNAS-SCALE).
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||||
|
|
||||||
@@ -348,7 +350,7 @@ TREK then launches fullscreen with its own icon, just like a native app.
|
|||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
||||||
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
|
Docker run, Helm, Portainer, Unraid, TrueNAS SCALE and Proxmox, plus the encryption-key note.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -437,8 +439,9 @@ Every variable, its default and what it does: see
|
|||||||
|
|
||||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
||||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); Ireland's counties come from
|
||||||
for full third-party attributions.
|
[**Tailte Éireann**](https://data-osi.opendata.arcgis.com/), also CC BY 4.0. See
|
||||||
|
[NOTICE.md](NOTICE.md) for full third-party attributions.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -43,7 +43,8 @@ See `values.yaml` for more options.
|
|||||||
## Notes
|
## Notes
|
||||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||||
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
|
- Both PVCs are `ReadWriteOnce`. Set `persistence.data.accessModes` and/or `persistence.uploads.accessModes` to override, for example `[ReadWriteMany]` so a backup pod can mount the volume. On the data volume such a pod may only read: SQLite corrupts `travel.db` when a second process writes it over a network filesystem.
|
||||||
|
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, accessModes, annotations) are then ignored.
|
||||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 5.1.1
|
version: 5.1.4
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "5.1.1"
|
appVersion: "5.1.4"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ metadata:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
{{- toYaml (.Values.persistence.data.accessModes | default (list "ReadWriteOnce")) | nindent 4 }}
|
||||||
{{- with .Values.persistence.data.storageClassName }}
|
{{- with .Values.persistence.data.storageClassName }}
|
||||||
storageClassName: {{ . | quote }}
|
storageClassName: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -33,7 +33,7 @@ metadata:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
{{- toYaml (.Values.persistence.uploads.accessModes | default (list "ReadWriteOnce")) | nindent 4 }}
|
||||||
{{- with .Values.persistence.uploads.storageClassName }}
|
{{- with .Values.persistence.uploads.storageClassName }}
|
||||||
storageClassName: {{ . | quote }}
|
storageClassName: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
+10
-1
@@ -247,12 +247,21 @@ persistence:
|
|||||||
size: 1Gi
|
size: 1Gi
|
||||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||||
storageClassName: ""
|
storageClassName: ""
|
||||||
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
|
# Access modes of the claim. ReadWriteMany lets another pod mount the volume, for
|
||||||
|
# example a backup sidecar, but only for reading: SQLite needs every writer on one
|
||||||
|
# host, so two pods writing travel.db over a network filesystem corrupt it.
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
# Bind an existing PVC. The other values (size, storageClassName, accessModes,
|
||||||
|
# annotations) are then ignored.
|
||||||
existingClaim: ""
|
existingClaim: ""
|
||||||
annotations: {}
|
annotations: {}
|
||||||
uploads:
|
uploads:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
storageClassName: ""
|
storageClassName: ""
|
||||||
|
# Access modes of the claim; ReadWriteMany lets other pods read or add uploads.
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
# Specify an existing PVC to bind. The other values are then ignored.
|
# Specify an existing PVC to bind. The other values are then ignored.
|
||||||
existingClaim: ""
|
existingClaim: ""
|
||||||
annotations: {}
|
annotations: {}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||||
npm run build # prebuild generates PWA icons, then vite build
|
npm run build # prebuild generates PWA icons, then vite build; postbuild writes a Brotli copy beside each hashed asset for the server to hand out
|
||||||
npm run typecheck # tsc --noEmit (CI)
|
npm run typecheck # tsc --noEmit (CI)
|
||||||
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
|
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
|
||||||
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
|
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-action-sheet')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-filesystem')
|
implementation project(':capacitor-filesystem')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-haptics')
|
||||||
implementation project(':capacitor-keyboard')
|
implementation project(':capacitor-keyboard')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.glance.appwidget.GlanceRemoteViews
|
|||||||
import com.liketrek.trek.tripday.TripDayNotification
|
import com.liketrek.trek.tripday.TripDayNotification
|
||||||
import com.liketrek.trek.widget.AtlasWidget
|
import com.liketrek.trek.widget.AtlasWidget
|
||||||
import com.liketrek.trek.widget.BookingsWidget
|
import com.liketrek.trek.widget.BookingsWidget
|
||||||
|
import com.liketrek.trek.widget.FlightWidget
|
||||||
import com.liketrek.trek.widget.CollabWidget
|
import com.liketrek.trek.widget.CollabWidget
|
||||||
import com.liketrek.trek.widget.PackingWidget
|
import com.liketrek.trek.widget.PackingWidget
|
||||||
import com.liketrek.trek.widget.TodayWidget
|
import com.liketrek.trek.widget.TodayWidget
|
||||||
@@ -74,6 +75,7 @@ class WidgetGalleryActivity : Activity() {
|
|||||||
"trip" to TripWidget(),
|
"trip" to TripWidget(),
|
||||||
"today" to TodayWidget(),
|
"today" to TodayWidget(),
|
||||||
"bookings" to BookingsWidget(),
|
"bookings" to BookingsWidget(),
|
||||||
|
"flight" to FlightWidget(),
|
||||||
"todos" to TodosWidget(),
|
"todos" to TodosWidget(),
|
||||||
"packing" to PackingWidget(),
|
"packing" to PackingWidget(),
|
||||||
"atlas" to AtlasWidget(),
|
"atlas" to AtlasWidget(),
|
||||||
|
|||||||
@@ -142,6 +142,15 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bookings" />
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bookings" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.FlightWidgetReceiver"
|
||||||
|
android:label="@string/widget_flight_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_flight" />
|
||||||
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.TodosWidgetReceiver"
|
android:name=".widget.TodosWidgetReceiver"
|
||||||
android:label="@string/widget_todos_name"
|
android:label="@string/widget_todos_name"
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package com.liketrek.trek;
|
package com.liketrek.trek;
|
||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.util.TypedValue;
|
||||||
|
import android.view.Gravity;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
import android.view.KeyboardShortcutGroup;
|
import android.view.KeyboardShortcutGroup;
|
||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
import com.liketrek.trek.screen.TrekKeyCommands;
|
import com.liketrek.trek.screen.TrekKeyCommands;
|
||||||
import com.liketrek.trek.tiles.TrekTileStore;
|
import com.liketrek.trek.tiles.TrekTileStore;
|
||||||
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
// runs and on its own origin. BridgeActivity reads `config` while it
|
// runs and on its own origin. BridgeActivity reads `config` while it
|
||||||
// builds the bridge inside super.onCreate().
|
// builds the bridge inside super.onCreate().
|
||||||
boolean restarting = TrekServerStore.consumeRestart(this);
|
boolean restarting = TrekServerStore.consumeRestart(this);
|
||||||
|
TrekServerStore.recheckAfterUpdate(this);
|
||||||
String server = TrekServerStore.getUrl(this);
|
String server = TrekServerStore.getUrl(this);
|
||||||
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
|
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
|
||||||
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
|
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
|
||||||
@@ -36,7 +45,17 @@ public class MainActivity extends BridgeActivity {
|
|||||||
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
// scroller natively. It scales the whole view, top bar and bottom dock included.
|
||||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||||
// Set before any WebView callback can run: they are posted to this thread.
|
// Set before any WebView callback can run: they are posted to this thread.
|
||||||
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
|
if (onServer) {
|
||||||
|
bridge.setWebViewClient(
|
||||||
|
new TrekWebViewClient(
|
||||||
|
bridge,
|
||||||
|
this::showAddressScreen,
|
||||||
|
this::showSignInBar,
|
||||||
|
this::showErrorPageBar,
|
||||||
|
TrekServerStore.getSignInHosts(this)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
TrekSharedFiles.receive(this, getIntent());
|
TrekSharedFiles.receive(this, getIntent());
|
||||||
// Android 15's widget picker draws the widgets live from a sample trip.
|
// Android 15's widget picker draws the widgets live from a sample trip.
|
||||||
TrekWidgetUpdater.publishPreviews(this);
|
TrekWidgetUpdater.publishPreviews(this);
|
||||||
@@ -68,6 +87,122 @@ public class MainActivity extends BridgeActivity {
|
|||||||
|
|
||||||
private boolean leavingServer;
|
private boolean leavingServer;
|
||||||
|
|
||||||
|
private View signInBar;
|
||||||
|
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
|
||||||
|
private boolean onSignInPage;
|
||||||
|
/** Whether the page is an error page in place of the server's (TrekWebViewClient.onReceivedHttpError). */
|
||||||
|
private boolean onErrorPage;
|
||||||
|
private boolean keyboardUp;
|
||||||
|
private TextView signInHint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
|
||||||
|
* not there, so nothing else would lead back to the address screen, and a
|
||||||
|
* passkey cannot work in a web view for a domain the app is not tied to.
|
||||||
|
* The bar says so and offers to change the server.
|
||||||
|
*/
|
||||||
|
private void showSignInBar(boolean show) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
onSignInPage = show;
|
||||||
|
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||||
|
updateSignInBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same bar over an error page in place of the server's, with words for that. */
|
||||||
|
private void showErrorPageBar(boolean show) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
onErrorPage = show;
|
||||||
|
if (show && signInBar == null) signInBar = buildSignInBar();
|
||||||
|
updateSignInBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
|
||||||
|
private void updateSignInBar() {
|
||||||
|
if (signInBar == null) return;
|
||||||
|
if (signInHint != null) {
|
||||||
|
signInHint.setText(onSignInPage ? TrekServerStore.signInHintLabel(this) : TrekServerStore.errorPageHintLabel(this));
|
||||||
|
}
|
||||||
|
signInBar.setVisibility((onSignInPage || onErrorPage) && !keyboardUp ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private View buildSignInBar() {
|
||||||
|
float dp = getResources().getDisplayMetrics().density;
|
||||||
|
LinearLayout bar = new LinearLayout(this);
|
||||||
|
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
bar.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
bar.setPaddingRelative(Math.round(16 * dp), Math.round(10 * dp), Math.round(10 * dp), Math.round(10 * dp));
|
||||||
|
// The look of the app's own first screens: a white card with a sand edge,
|
||||||
|
// warm grey text and a dark button, over whatever page the proxy shows.
|
||||||
|
GradientDrawable background = new GradientDrawable();
|
||||||
|
background.setColor(0xF7FFFFFF);
|
||||||
|
background.setCornerRadius(18 * dp);
|
||||||
|
background.setStroke(Math.max(1, Math.round(dp)), 0xFFEFE4D8);
|
||||||
|
bar.setBackground(background);
|
||||||
|
bar.setElevation(8 * dp);
|
||||||
|
|
||||||
|
TextView hint = new TextView(this);
|
||||||
|
signInHint = hint;
|
||||||
|
hint.setText(TrekServerStore.signInHintLabel(this));
|
||||||
|
hint.setTextColor(0xFF6B5F57);
|
||||||
|
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
|
||||||
|
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||||
|
|
||||||
|
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
|
||||||
|
change.setText(TrekServerStore.changeServerLabel(this));
|
||||||
|
change.setAllCaps(false);
|
||||||
|
change.setTextColor(0xFFFFFFFF);
|
||||||
|
GradientDrawable pill = new GradientDrawable();
|
||||||
|
pill.setColor(0xFF111827);
|
||||||
|
pill.setCornerRadius(12 * dp);
|
||||||
|
change.setBackground(pill);
|
||||||
|
change.setMinHeight(0);
|
||||||
|
change.setMinimumHeight(0);
|
||||||
|
change.setPaddingRelative(Math.round(14 * dp), Math.round(9 * dp), Math.round(14 * dp), Math.round(9 * dp));
|
||||||
|
LinearLayout.LayoutParams changeParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
changeParams.setMarginStart(Math.round(10 * dp));
|
||||||
|
change.setOnClickListener((v) -> {
|
||||||
|
TrekServerStore.setUrl(this, null);
|
||||||
|
TrekServerStore.markRestart(this);
|
||||||
|
recreate();
|
||||||
|
});
|
||||||
|
bar.addView(change, changeParams);
|
||||||
|
|
||||||
|
int margin = Math.round(12 * dp);
|
||||||
|
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
Gravity.BOTTOM
|
||||||
|
);
|
||||||
|
params.setMargins(margin, 0, margin, margin);
|
||||||
|
FrameLayout root = findViewById(android.R.id.content);
|
||||||
|
root.addView(bar, params);
|
||||||
|
// Above the gesture bar, and above the keyboard while it is up.
|
||||||
|
bar.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
// Read off the window's own insets: Capacitor hands the views below the
|
||||||
|
// decor a copy with the system bars zeroed, and before Android 11 that
|
||||||
|
// takes the keyboard with it.
|
||||||
|
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
|
||||||
|
int bottom = window != null
|
||||||
|
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
|
||||||
|
: insets.getStableInsetBottom();
|
||||||
|
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
|
||||||
|
if (imeVisible != keyboardUp) {
|
||||||
|
keyboardUp = imeVisible;
|
||||||
|
updateSignInBar();
|
||||||
|
}
|
||||||
|
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
|
||||||
|
if (p.bottomMargin != margin + bottom) {
|
||||||
|
p.bottomMargin = margin + bottom;
|
||||||
|
v.setLayoutParams(p);
|
||||||
|
}
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
bar.requestApplyInsets();
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The server did not load: back to the address screen, which offers retry
|
* The server did not load: back to the address screen, which offers retry
|
||||||
* and change. A fresh task rather than recreate(), because this usually
|
* and change. A fresh task rather than recreate(), because this usually
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,17 +22,99 @@ final class TrekServerStore {
|
|||||||
|
|
||||||
private static final String PREFS = "trek_shell";
|
private static final String PREFS = "trek_shell";
|
||||||
private static final String KEY_URL = "server_url";
|
private static final String KEY_URL = "server_url";
|
||||||
|
private static final String KEY_PREVIOUS_URL = "previous_server_url";
|
||||||
|
private static final String KEY_SERVER_CHECK = "server_check";
|
||||||
|
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
|
||||||
|
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
|
||||||
|
private static final String KEY_LABEL_ERROR_PAGE_HINT = "label_error_page_hint";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
|
||||||
|
* server remembered under a lower value is checked once more: the first
|
||||||
|
* launch after an update that raised it puts the address screen back up,
|
||||||
|
* filled in with that server (getPreviousUrl). Raise both together.
|
||||||
|
*/
|
||||||
|
private static final int SERVER_CHECK = 3;
|
||||||
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
|
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_SHELL_ONCE = "shell_once";
|
||||||
private static final String KEY_RESTART = "restart";
|
private static final String KEY_RESTART = "restart";
|
||||||
|
private static final String KEY_WELCOME = "welcome";
|
||||||
private static final String CONFIG_DIR = "trek-server-config";
|
private static final String CONFIG_DIR = "trek-server-config";
|
||||||
|
|
||||||
private TrekServerStore() {}
|
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) {
|
static String getUrl(Context context) {
|
||||||
return prefs(context).getString(KEY_URL, null);
|
return prefs(context).getString(KEY_URL, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
|
||||||
|
static void recheckAfterUpdate(Context context) {
|
||||||
|
SharedPreferences prefs = prefs(context);
|
||||||
|
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
|
||||||
|
String server = getUrl(context);
|
||||||
|
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
|
||||||
|
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
|
||||||
|
editor.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The words of the bar over a proxy's sign-in page, in the user's language,
|
||||||
|
* as the address screen passed them (setServer's `labels`): the server's UI
|
||||||
|
* is not there to say them. Null keeps the English fallback.
|
||||||
|
*/
|
||||||
|
static void setLabels(Context context, String signInHint, String changeServer, String errorPageHint) {
|
||||||
|
SharedPreferences.Editor editor = prefs(context).edit();
|
||||||
|
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
|
||||||
|
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
|
||||||
|
if (errorPageHint != null) editor.putString(KEY_LABEL_ERROR_PAGE_HINT, errorPageHint);
|
||||||
|
editor.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String errorPageHintLabel(Context context) {
|
||||||
|
return prefs(context).getString(
|
||||||
|
KEY_LABEL_ERROR_PAGE_HINT,
|
||||||
|
"This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String signInHintLabel(Context context) {
|
||||||
|
return prefs(context).getString(
|
||||||
|
KEY_LABEL_SIGN_IN_HINT,
|
||||||
|
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String changeServerLabel(Context context) {
|
||||||
|
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server an update asked to check again, until a server is set. */
|
||||||
|
static String getPreviousUrl(Context context) {
|
||||||
|
return prefs(context).getString(KEY_PREVIOUS_URL, null);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
|
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
|
||||||
static void setUrl(Context context, String url) {
|
static void setUrl(Context context, String url) {
|
||||||
setServer(context, url, null);
|
setServer(context, url, null);
|
||||||
@@ -43,7 +125,7 @@ final class TrekServerStore {
|
|||||||
if (url == null) {
|
if (url == null) {
|
||||||
editor.remove(KEY_URL);
|
editor.remove(KEY_URL);
|
||||||
} else {
|
} else {
|
||||||
editor.putString(KEY_URL, url);
|
editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
|
||||||
}
|
}
|
||||||
if (signInHosts == null || signInHosts.isEmpty()) {
|
if (signInHosts == null || signInHosts.isEmpty()) {
|
||||||
editor.remove(KEY_SIGN_IN_HOSTS);
|
editor.remove(KEY_SIGN_IN_HOSTS);
|
||||||
@@ -66,7 +148,32 @@ final class TrekServerStore {
|
|||||||
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
|
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. */
|
/** Start the next launch on the bundled address screen, even with a server remembered. */
|
||||||
|
private static final String KEY_FAILURE = "last_failure";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the server's page did not load, in the WebView's own terms
|
||||||
|
* ("net::ERR_NAME_NOT_RESOLVED (-2)", "HTTP 503"): the address screen shows
|
||||||
|
* it small under its message, so a report carries the cause.
|
||||||
|
*/
|
||||||
|
static void setFailure(Context context, String failure) {
|
||||||
|
prefs(context).edit().putString(KEY_FAILURE, failure).commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The failure behind this address screen, once. */
|
||||||
|
static String takeFailure(Context context) {
|
||||||
|
String failure = prefs(context).getString(KEY_FAILURE, null);
|
||||||
|
if (failure != null) prefs(context).edit().remove(KEY_FAILURE).commit();
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
static void showShellOnce(Context context) {
|
static void showShellOnce(Context context) {
|
||||||
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package com.liketrek.trek;
|
package com.liketrek.trek;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
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.browser.customtabs.CustomTabsIntent;
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
import com.getcapacitor.JSArray;
|
import com.getcapacitor.JSArray;
|
||||||
@@ -15,6 +20,7 @@ import com.getcapacitor.PermissionState;
|
|||||||
import com.getcapacitor.Plugin;
|
import com.getcapacitor.Plugin;
|
||||||
import com.getcapacitor.PluginCall;
|
import com.getcapacitor.PluginCall;
|
||||||
import com.getcapacitor.PluginMethod;
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.ActivityCallback;
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
import com.getcapacitor.annotation.Permission;
|
import com.getcapacitor.annotation.Permission;
|
||||||
import com.getcapacitor.annotation.PermissionCallback;
|
import com.getcapacitor.annotation.PermissionCallback;
|
||||||
@@ -65,6 +71,8 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
"googleWallet",
|
"googleWallet",
|
||||||
"keyCommands",
|
"keyCommands",
|
||||||
"windowLayout",
|
"windowLayout",
|
||||||
|
"clientCertificate",
|
||||||
|
"filePreview",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
|
||||||
@@ -262,11 +270,53 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
call.resolve();
|
call.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
|
||||||
|
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
|
||||||
|
* app on the device views the type; the UI then hands it to the share sheet.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
public void previewFile(PluginCall call) {
|
||||||
|
String path = call.getString("path");
|
||||||
|
Uri parsed = path == null ? null : Uri.parse(path);
|
||||||
|
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
|
||||||
|
java.io.File cache = getContext().getCacheDir();
|
||||||
|
boolean inCache;
|
||||||
|
try {
|
||||||
|
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
inCache = false;
|
||||||
|
}
|
||||||
|
if (!inCache || !file.isFile()) {
|
||||||
|
call.reject("a file in the app's cache is required", "INVALID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String type = call.getString("mimeType", "application/octet-stream");
|
||||||
|
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
|
||||||
|
Intent view = new Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(content, type)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
getActivity().runOnUiThread(() -> {
|
||||||
|
JSObject result = new JSObject();
|
||||||
|
try {
|
||||||
|
getActivity().startActivity(view);
|
||||||
|
result.put("result", "shown");
|
||||||
|
} catch (ActivityNotFoundException e) {
|
||||||
|
result.put("result", "unsupported");
|
||||||
|
}
|
||||||
|
call.resolve(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
public void getServer(PluginCall call) {
|
public void getServer(PluginCall call) {
|
||||||
String url = TrekServerStore.getUrl(getContext());
|
String url = TrekServerStore.getUrl(getContext());
|
||||||
|
String previous = TrekServerStore.getPreviousUrl(getContext());
|
||||||
JSObject result = new JSObject();
|
JSObject result = new JSObject();
|
||||||
result.put("url", url == null ? JSONObject.NULL : url);
|
result.put("url", url == null ? JSONObject.NULL : url);
|
||||||
|
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
|
||||||
|
String failure = TrekServerStore.takeFailure(getContext());
|
||||||
|
result.put("failure", failure == null ? JSONObject.NULL : failure);
|
||||||
call.resolve(result);
|
call.resolve(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +329,18 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
|
||||||
|
JSObject labels = call.getObject("labels");
|
||||||
|
if (labels != null) {
|
||||||
|
TrekServerStore.setLabels(
|
||||||
|
getContext(),
|
||||||
|
labels.getString("signInHint"),
|
||||||
|
labels.getString("changeServer"),
|
||||||
|
labels.getString("errorPageHint")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 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();
|
call.resolve();
|
||||||
restart();
|
restart();
|
||||||
}
|
}
|
||||||
@@ -374,7 +436,11 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
Uri data = intent.getData();
|
Uri data = intent.getData();
|
||||||
PluginCall call = pendingAuthCall();
|
PluginCall call = pendingAuthCall();
|
||||||
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
|
if (call == null) {
|
||||||
|
keepOrphanAuthReturn(intent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data == null || !authScheme.equals(data.getScheme())) return;
|
||||||
JSObject result = new JSObject();
|
JSObject result = new JSObject();
|
||||||
result.put("url", data.toString());
|
result.put("url", data.toString());
|
||||||
call.resolve(result);
|
call.resolve(result);
|
||||||
@@ -400,6 +466,48 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
}, CANCEL_GRACE_MS);
|
}, CANCEL_GRACE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final String AUTH_PREFS = "trek_auth_return";
|
||||||
|
/** As long as the server keeps the code it carries (NATIVE_HANDOFF_CODE_TTL_MS), with room to spare. */
|
||||||
|
private static final long ORPHAN_AUTH_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A return to the sign-in scheme with no call waiting for it: Android ended
|
||||||
|
* the app while the browser was in front, and the browser's return started
|
||||||
|
* it anew (the launch intent comes through here as well). Kept on disk, as
|
||||||
|
* the process may be new too, until the sign-in page takes it. The return
|
||||||
|
* is then taken off the activity's intent: Capacitor hands the launch intent
|
||||||
|
* in again whenever the activity is created, and Android does as well when
|
||||||
|
* it rebuilds the task from the recent apps.
|
||||||
|
*/
|
||||||
|
private void keepOrphanAuthReturn(Intent intent) {
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data == null || !"auth".equals(data.getHost())) return;
|
||||||
|
if (!getContext().getString(R.string.custom_url_scheme).equals(data.getScheme())) return;
|
||||||
|
if (getActivity() != null && getActivity().getIntent() != null && getActivity().getIntent().getData() != null
|
||||||
|
&& data.equals(getActivity().getIntent().getData())) {
|
||||||
|
getActivity().setIntent(new Intent(getActivity().getIntent()).setData(null));
|
||||||
|
}
|
||||||
|
if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return;
|
||||||
|
getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString("url", data.toString())
|
||||||
|
.putLong("at", System.currentTimeMillis())
|
||||||
|
.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kept return of a sign-in (keepOrphanAuthReturn), once and while fresh; null otherwise. */
|
||||||
|
@PluginMethod
|
||||||
|
public void takeAuthCallback(PluginCall call) {
|
||||||
|
android.content.SharedPreferences prefs = getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE);
|
||||||
|
String url = prefs.getString("url", null);
|
||||||
|
long at = prefs.getLong("at", 0);
|
||||||
|
prefs.edit().clear().commit();
|
||||||
|
boolean fresh = url != null && System.currentTimeMillis() - at <= ORPHAN_AUTH_TTL_MS;
|
||||||
|
JSObject result = new JSObject();
|
||||||
|
result.put("url", fresh ? url : JSONObject.NULL);
|
||||||
|
call.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
private PluginCall pendingAuthCall() {
|
private PluginCall pendingAuthCall() {
|
||||||
return authCallId == null ? null : getBridge().getSavedCall(authCallId);
|
return authCallId == null ? null : getBridge().getSavedCall(authCallId);
|
||||||
}
|
}
|
||||||
@@ -625,6 +733,12 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
|
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this Google account already holds a delivered cloud subscription. */
|
||||||
|
@PluginMethod
|
||||||
|
public void storeHeld(PluginCall call) {
|
||||||
|
com.liketrek.trek.billing.TrekBilling.INSTANCE.held(getContext(), call);
|
||||||
|
}
|
||||||
|
|
||||||
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
|
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
public void storeUnfinished(PluginCall call) {
|
public void storeUnfinished(PluginCall call) {
|
||||||
@@ -673,4 +787,129 @@ public class TrekShellPlugin extends Plugin {
|
|||||||
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
|
// 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())));
|
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Client certificates (mutual TLS) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address screen's check of a server, made natively so it notices
|
||||||
|
* whether the server asks for a client certificate and can show the stored
|
||||||
|
* one (clientcert/ClientCertActions.request).
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
public void requestServer(PluginCall call) {
|
||||||
|
String url = call.getString("url");
|
||||||
|
Uri parsed = url == null ? null : Uri.parse(url);
|
||||||
|
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
|
||||||
|
call.reject("A http(s) URL is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
call.resolve(ClientCertActions.request(getContext(), url, timeout));
|
||||||
|
} catch (Exception e) {
|
||||||
|
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
|
||||||
|
}
|
||||||
|
}, "trek-request-server").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored certificate's details, or none. */
|
||||||
|
@PluginMethod
|
||||||
|
public void clientCertificate(PluginCall call) {
|
||||||
|
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||||
|
JSObject result = new JSObject();
|
||||||
|
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
|
||||||
|
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
|
||||||
|
if (asked != null) {
|
||||||
|
JSObject request = new JSObject();
|
||||||
|
request.put("host", asked.getHost());
|
||||||
|
request.put("port", asked.getPort());
|
||||||
|
request.put("served", asked.getServed());
|
||||||
|
result.put("request", request);
|
||||||
|
}
|
||||||
|
call.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void removeClientCertificate(PluginCall call) {
|
||||||
|
TrekClientIdentity.remove(getContext());
|
||||||
|
forgetCertificateAnswers();
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
|
||||||
|
private void forgetCertificateAnswers() {
|
||||||
|
com.liketrek.trek.clientcert.ClientCertWatch.reset();
|
||||||
|
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a .p12/.pfx file, asks for its password until it opens or the user
|
||||||
|
* gives up, and keeps it for the server at host and port. Resolves with
|
||||||
|
* result "imported" (and the certificate), "cancelled", or "failed" and why.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
public void importClientCertificate(PluginCall call) {
|
||||||
|
String host = call.getString("host");
|
||||||
|
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
|
||||||
|
call.reject("A host is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
|
||||||
|
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
|
||||||
|
startActivityForResult(call, pick, "clientCertificatePicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ActivityCallback
|
||||||
|
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
|
||||||
|
if (call == null) return;
|
||||||
|
Uri uri = picked.getData() == null ? null : picked.getData().getData();
|
||||||
|
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
|
||||||
|
resolveImport(call, "cancelled", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new Thread(() -> {
|
||||||
|
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
|
||||||
|
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
|
||||||
|
else askPasswordFor(call, bytes, false);
|
||||||
|
}, "trek-client-cert").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
|
||||||
|
JSObject labels = call.getObject("labels", new JSObject());
|
||||||
|
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
|
||||||
|
if (password == null) {
|
||||||
|
resolveImport(call, "cancelled", null);
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
}
|
||||||
|
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openWith(PluginCall call, byte[] bytes, char[] password) {
|
||||||
|
try {
|
||||||
|
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
|
||||||
|
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
|
||||||
|
forgetCertificateAnswers();
|
||||||
|
resolveImport(call, "imported", null);
|
||||||
|
} catch (TrekClientIdentity.ImportException e) {
|
||||||
|
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
|
||||||
|
else resolveImport(call, "failed", e.getReason().name());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "client certificate not kept", e);
|
||||||
|
resolveImport(call, "failed", "STORE");
|
||||||
|
} finally {
|
||||||
|
java.util.Arrays.fill(password, '\0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resolveImport(PluginCall call, String result, String failure) {
|
||||||
|
JSObject out = new JSObject();
|
||||||
|
out.put("result", result);
|
||||||
|
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
|
||||||
|
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
|
||||||
|
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
|
||||||
|
call.resolve(out);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
package com.liketrek.trek;
|
package com.liketrek.trek;
|
||||||
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.ClientCertRequest;
|
||||||
import android.webkit.WebResourceError;
|
import android.webkit.WebResourceError;
|
||||||
import android.webkit.WebResourceRequest;
|
import android.webkit.WebResourceRequest;
|
||||||
import android.webkit.WebResourceResponse;
|
import android.webkit.WebResourceResponse;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
import com.getcapacitor.Bridge;
|
import com.getcapacitor.Bridge;
|
||||||
import com.getcapacitor.BridgeWebViewClient;
|
import com.getcapacitor.BridgeWebViewClient;
|
||||||
|
import com.liketrek.trek.clientcert.TrekClientIdentity;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used while the WebView shows the user's server. When that server cannot be
|
* Used while the WebView shows the user's server. When that server cannot be
|
||||||
@@ -21,12 +27,32 @@ import java.util.Set;
|
|||||||
class TrekWebViewClient extends BridgeWebViewClient {
|
class TrekWebViewClient extends BridgeWebViewClient {
|
||||||
|
|
||||||
private final Runnable onUnreachable;
|
private final Runnable onUnreachable;
|
||||||
|
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
|
||||||
|
private final Consumer<Boolean> onSignInPage;
|
||||||
|
/** Told whether the page now shown is an error page in place of the server's, for the same bar. */
|
||||||
|
private final Consumer<Boolean> onErrorPage;
|
||||||
|
/** The main-frame address that last came back as a 4xx page nobody else handled. */
|
||||||
|
private String errorPageUrl;
|
||||||
private final Set<String> signInHosts;
|
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;
|
||||||
|
/** The page itself failed on the network once already; the next failure goes to the address screen. */
|
||||||
|
private boolean retriedMainFrame;
|
||||||
|
/** The page loaded again after a network error, until that load starts: its error page counts for nothing. */
|
||||||
|
private String retryUrl;
|
||||||
|
|
||||||
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
|
TrekWebViewClient(
|
||||||
|
Bridge bridge,
|
||||||
|
Runnable onUnreachable,
|
||||||
|
Consumer<Boolean> onSignInPage,
|
||||||
|
Consumer<Boolean> onErrorPage,
|
||||||
|
Set<String> signInHosts
|
||||||
|
) {
|
||||||
super(bridge);
|
super(bridge);
|
||||||
this.onUnreachable = onUnreachable;
|
this.onUnreachable = onUnreachable;
|
||||||
this.signInHosts = signInHosts;
|
this.onSignInPage = onSignInPage;
|
||||||
|
this.onErrorPage = onErrorPage;
|
||||||
|
this.signInHosts = new HashSet<>(signInHosts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,9 +67,46 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
|||||||
@Override
|
@Override
|
||||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||||
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
|
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);
|
return super.shouldOverrideUrlLoading(view, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
|
||||||
|
@Override
|
||||||
|
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
|
||||||
|
super.doUpdateVisitedHistory(view, url, isReload);
|
||||||
|
if (retryUrl != null) return;
|
||||||
|
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
|
||||||
|
// The 4xx answer arrives before this commit or after it; either way the
|
||||||
|
// flag is read once here, so the next page clears the bar again.
|
||||||
|
boolean error = url != null && url.equals(errorPageUrl);
|
||||||
|
errorPageUrl = null;
|
||||||
|
onErrorPage.accept(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
// The error page of a load that is about to be retried: not the server's
|
||||||
|
// first page, so no welcome page and no learning of sign-in hosts yet.
|
||||||
|
if (retryUrl != null) return;
|
||||||
|
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) {
|
private boolean isSignInHost(Uri url) {
|
||||||
String scheme = url.getScheme();
|
String scheme = url.getScheme();
|
||||||
String host = url.getHost();
|
String host = url.getHost();
|
||||||
@@ -62,9 +125,53 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
|||||||
return super.shouldInterceptRequest(view, request);
|
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, () -> {
|
||||||
|
TrekServerStore.setFailure(view.getContext(), "client certificate requested");
|
||||||
|
onUnreachable.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page that failed on the network is loaded once more before the app says
|
||||||
|
* the server is unreachable. Started from the background, as when the
|
||||||
|
* browser hands a sign-in back to an app Android had ended, a Samsung phone
|
||||||
|
* may hold its network back for a moment, and the first load fails with the
|
||||||
|
* server there all along. The error is logged, as a report has nothing else.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||||
if (request.isForMainFrame()) {
|
if (request.isForMainFrame()) {
|
||||||
|
Log.w("TrekShell", "Page failed: " + error.getErrorCode() + " " + error.getDescription());
|
||||||
|
if (!retriedMainFrame && isNetworkError(error.getErrorCode())) {
|
||||||
|
retriedMainFrame = true;
|
||||||
|
retryUrl = request.getUrl().toString();
|
||||||
|
view.postDelayed(() -> {
|
||||||
|
String url = retryUrl;
|
||||||
|
retryUrl = null;
|
||||||
|
view.loadUrl(url);
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TrekServerStore.setFailure(view.getContext(), error.getDescription() + " (" + error.getErrorCode() + ")");
|
||||||
onUnreachable.run();
|
onUnreachable.run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,15 +180,37 @@ class TrekWebViewClient extends BridgeWebViewClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
|
* 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,
|
* 503 while TREK restarts). A 4xx page is not TREK's, which serves its UI for
|
||||||
* and TREK serves its UI for every other path.
|
* every path: a proxy that asked this page for a client certificate and got
|
||||||
|
* none, as Cloudflare does with a 403, sends the app to the address screen,
|
||||||
|
* which says so and offers to add one. Any other 4xx page (a proxy's block
|
||||||
|
* page, a certificate it refused) stays up with the bar and its way out, so
|
||||||
|
* the app is never stuck on a page without TREK's UI.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
|
||||||
|
int status = errorResponse.getStatusCode();
|
||||||
if (!request.isForMainFrame()) {
|
if (!request.isForMainFrame()) {
|
||||||
super.onReceivedHttpError(view, request, errorResponse);
|
super.onReceivedHttpError(view, request, errorResponse);
|
||||||
} else if (errorResponse.getStatusCode() >= 500) {
|
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
|
||||||
|
TrekServerStore.setFailure(view.getContext(), "HTTP " + status);
|
||||||
onUnreachable.run();
|
onUnreachable.run();
|
||||||
|
} else if (status >= 400) {
|
||||||
|
errorPageUrl = request.getUrl().toString();
|
||||||
|
onErrorPage.accept(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isNetworkError(int code) {
|
||||||
|
return code == WebViewClient.ERROR_HOST_LOOKUP
|
||||||
|
|| code == WebViewClient.ERROR_CONNECT
|
||||||
|
|| code == WebViewClient.ERROR_TIMEOUT
|
||||||
|
|| code == WebViewClient.ERROR_IO
|
||||||
|
|| code == WebViewClient.ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,23 +119,38 @@ object TrekBilling {
|
|||||||
val id = call.getString("productId") ?: return call.reject("productId is required")
|
val id = call.getString("productId") ?: return call.reject("productId is required")
|
||||||
ready(activity) { ok ->
|
ready(activity) { ok ->
|
||||||
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
|
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
|
||||||
details(activity, listOf(id)) { found ->
|
// One subscription per Google account: a second plan would run beside the first.
|
||||||
val product = found.firstOrNull()
|
// A delivered one stops the purchase here; one that was paid but never set up is
|
||||||
val offer = product?.let(::baseOffer)
|
// handed back as bought, so its instance is set up now.
|
||||||
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
|
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
|
||||||
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
|
client(activity).queryPurchasesAsync(params) { result, purchases ->
|
||||||
pendingPurchase = call
|
val held = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
|
||||||
pendingProduct = id
|
when {
|
||||||
val params = BillingFlowParams.newBuilder()
|
held == null -> launchPurchase(activity, call, id)
|
||||||
.setProductDetailsParamsList(
|
held.isAcknowledged -> call.resolve(JSObject().put("kind", "subscribed"))
|
||||||
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
|
else -> call.resolve(receipt(held).put("kind", "bought"))
|
||||||
)
|
}
|
||||||
.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 launchPurchase(activity: Activity, call: PluginCall, id: String) {
|
||||||
}
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +223,19 @@ object TrekBilling {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this Google account holds a delivered subscription under `prefix`; while one runs, the shell offers no purchase. */
|
||||||
|
fun held(context: Context, call: PluginCall) {
|
||||||
|
val prefix = call.getString("prefix") ?: PREFIX
|
||||||
|
ready(context) { ok ->
|
||||||
|
if (!ok) return@ready call.resolve(JSObject().put("held", false))
|
||||||
|
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
|
||||||
|
call.resolve(JSObject().put("held", found?.isAcknowledged == true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The newest active subscription under `prefix` on this Google account, if any. */
|
/** The newest active subscription under `prefix` on this Google account, if any. */
|
||||||
fun restore(context: Context, call: PluginCall) {
|
fun restore(context: Context, call: PluginCall) {
|
||||||
val prefix = call.getString("prefix") ?: "cloud."
|
val prefix = call.getString("prefix") ?: "cloud."
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@ class BookingsWidgetReceiver : TrekWidgetReceiver() {
|
|||||||
override val glanceAppWidget: GlanceAppWidget = BookingsWidget()
|
override val glanceAppWidget: GlanceAppWidget = BookingsWidget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FlightWidgetReceiver : TrekWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = FlightWidget()
|
||||||
|
}
|
||||||
|
|
||||||
class TodosWidgetReceiver : TrekWidgetReceiver() {
|
class TodosWidgetReceiver : TrekWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = TodosWidget()
|
override val glanceAppWidget: GlanceAppWidget = TodosWidget()
|
||||||
}
|
}
|
||||||
@@ -56,6 +60,7 @@ object TrekWidgetUpdater {
|
|||||||
TripWidgetReceiver::class,
|
TripWidgetReceiver::class,
|
||||||
TodayWidgetReceiver::class,
|
TodayWidgetReceiver::class,
|
||||||
BookingsWidgetReceiver::class,
|
BookingsWidgetReceiver::class,
|
||||||
|
FlightWidgetReceiver::class,
|
||||||
TodosWidgetReceiver::class,
|
TodosWidgetReceiver::class,
|
||||||
PackingWidgetReceiver::class,
|
PackingWidgetReceiver::class,
|
||||||
AtlasWidgetReceiver::class,
|
AtlasWidgetReceiver::class,
|
||||||
|
|||||||
@@ -448,6 +448,173 @@ private fun BookingRow(booking: WidgetSnapshot.Booking, labels: WidgetSnapshot.L
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Flight ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a flight stands now (as FlightMoment on iOS). What the feed saw wins: a
|
||||||
|
* flight still at the gate past its planned departure is not flown along the
|
||||||
|
* arc, and it is only landed once confirmed. Only a flight the feed never heard
|
||||||
|
* of moves on by its schedule alone; a lost one stops two hours after arrival.
|
||||||
|
*/
|
||||||
|
private class FlightMoment(f: WidgetSnapshot.Flight, nowSeconds: Long) {
|
||||||
|
val start: Long = f.depActual ?: f.depExpected ?: f.dep
|
||||||
|
val end: Long = maxOf(f.arrExpected ?: f.arr, start + 60)
|
||||||
|
private val heard = f.seen != null
|
||||||
|
val landed = f.landed || (!heard && nowSeconds >= end) || nowSeconds >= end + 7200
|
||||||
|
val airborne = !landed && (f.airborne || f.depActual != null || (!heard && nowSeconds >= start))
|
||||||
|
val ready = !landed && !airborne && nowSeconds >= start
|
||||||
|
val progress: Float = when {
|
||||||
|
landed -> 1f
|
||||||
|
airborne -> ((nowSeconds - start).toFloat() / (end - start)).coerceIn(0f, 0.98f)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A duration in the app's words: "2 d 4 h", "1 h 5 min", "12 min". */
|
||||||
|
private fun durationText(seconds: Long, labels: WidgetSnapshot.Labels): String {
|
||||||
|
val minutes = ((seconds + 59) / 60).coerceAtLeast(1)
|
||||||
|
val days = minutes / 1440
|
||||||
|
val hours = (minutes % 1440) / 60
|
||||||
|
val rest = minutes % 60
|
||||||
|
return when {
|
||||||
|
days > 0 -> labels.durDaysHours.replace("{d}", "$days").replace("{h}", "$hours")
|
||||||
|
hours > 0 -> labels.durHoursMinutes.replace("{h}", "$hours").replace("{m}", "$rest")
|
||||||
|
else -> labels.durMinutes.replace("{m}", "$rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Departs in 2 h 10 min", "Lands in 1 h 5 min", "Landed". */
|
||||||
|
private fun momentText(m: FlightMoment, labels: WidgetSnapshot.Labels, nowSeconds: Long): String = when {
|
||||||
|
m.landed -> labels.flightLanded
|
||||||
|
m.ready -> labels.flightReady
|
||||||
|
m.airborne -> labels.flightLands.replace("{time}", durationText(m.end - nowSeconds, labels))
|
||||||
|
else -> labels.flightDeparts.replace("{time}", durationText(m.start - nowSeconds, labels))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The delay as a chip's text and colour; nothing to say without data. */
|
||||||
|
private fun delayChip(delay: Int?, labels: WidgetSnapshot.Labels): Pair<String, ColorProvider>? = when {
|
||||||
|
delay == null -> null
|
||||||
|
delay > 5 -> labels.flightLate.replace("{m}", "$delay") to (if (delay >= 30) TrekStatus.danger else TrekStatus.pending)
|
||||||
|
delay < -5 -> labels.flightEarly.replace("{m}", "${-delay}") to TrekStatus.confirmed
|
||||||
|
else -> labels.flightOnTime to TrekStatus.confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FLIGHT_BLUE = 0xFF4A7DDB.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip's next flight, or the one in the air: both airports large, the
|
||||||
|
* route as an arc with the aircraft on it, the moment in words and the delay.
|
||||||
|
* The arc and the words follow the clock each time the widget is drawn.
|
||||||
|
*/
|
||||||
|
class FlightWidget : 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 flight = snapshot.flight
|
||||||
|
WidgetFrame(onClick = openPath(context, flight?.path ?: snapshot.trip?.path ?: "/")) {
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 14.dp)
|
||||||
|
Spacer(GlanceModifier.width(5.dp))
|
||||||
|
Eyebrow(flight?.number ?: labels.flight, GlanceTheme.colors.primary, GlanceModifier.defaultWeight())
|
||||||
|
flight?.let { f ->
|
||||||
|
delayChip(f.delay, labels)?.let { (text, color) ->
|
||||||
|
if (size.isWide || (f.delay ?: 0) > 5) Chip(text, GlanceTheme.colors.surfaceVariant, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flight == null) {
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
Line(labels.noFlight, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2)
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
} else {
|
||||||
|
FlightBody(flight, labels, size.isWide, size.isTall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The origin stands on the right in a right-to-left layout, so the drawn arc is
|
||||||
|
* mirrored with it. Read from the locale the widget speaks, not from the
|
||||||
|
* Configuration, which a composition may not read directly.
|
||||||
|
*/
|
||||||
|
private fun isRtl(): Boolean =
|
||||||
|
android.text.TextUtils.getLayoutDirectionFromLocale(java.util.Locale.getDefault()) == android.view.View.LAYOUT_DIRECTION_RTL
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.FlightBody(f: WidgetSnapshot.Flight, labels: WidgetSnapshot.Labels, wide: Boolean, tall: Boolean) {
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
val m = FlightMoment(f, now)
|
||||||
|
val arrival = f.arrTimeExpected ?: f.arrTime
|
||||||
|
val momentColor = if (m.airborne) GlanceTheme.colors.primary else GlanceTheme.colors.onSurface
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
if (!wide) Line(momentText(m, labels, now), 13.sp, momentColor, FontWeight.Bold, maxLines = 2)
|
||||||
|
Spacer(GlanceModifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
|
Line(f.from, if (wide) 26.sp else 22.sp, weight = FontWeight.Bold)
|
||||||
|
if (wide) f.fromCity?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
if (wide) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(WidgetArt.flightArc(m.progress, FLIGHT_BLUE, android.graphics.Color.argb(90, 120, 120, 130), rtl = isRtl())),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = GlanceModifier.width(120.dp).height(26.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
|
||||||
|
Line(f.to, if (wide) 26.sp else 22.sp, weight = FontWeight.Bold)
|
||||||
|
if (wide) f.toCity?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!wide) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(WidgetArt.flightArc(m.progress, FLIGHT_BLUE, android.graphics.Color.argb(90, 120, 120, 130), rtl = isRtl())),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().height(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
Line(f.depTime, 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold, modifier = GlanceModifier.defaultWeight())
|
||||||
|
Line(arrival, 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold)
|
||||||
|
}
|
||||||
|
if (wide) {
|
||||||
|
Spacer(GlanceModifier.height(8.dp))
|
||||||
|
Line(momentText(m, labels, now), 13.sp, momentColor, FontWeight.Bold)
|
||||||
|
if (m.airborne) {
|
||||||
|
Spacer(GlanceModifier.height(5.dp))
|
||||||
|
Bar(m.progress, GlanceTheme.colors.primary, GlanceTheme.colors.surfaceVariant, 5.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tall) {
|
||||||
|
Spacer(GlanceModifier.height(10.dp))
|
||||||
|
f.aircraft?.let { FlightFact(it) }
|
||||||
|
f.fromCity?.let { from -> f.toCity?.let { to -> FlightFact("$from \u2192 $to") } }
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.defaultWeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FlightFact(text: String) {
|
||||||
|
Row(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().background(GlanceTheme.colors.surfaceVariant).cornerRadius(12.dp)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 13.dp)
|
||||||
|
Spacer(GlanceModifier.width(7.dp))
|
||||||
|
Line(text, 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(5.dp))
|
||||||
|
}
|
||||||
|
|
||||||
// ---- To-dos ----
|
// ---- To-dos ----
|
||||||
|
|
||||||
/** What is still open for the trip, and how far the packing has come. */
|
/** What is still open for the trip, and how far the packing has come. */
|
||||||
|
|||||||
@@ -149,4 +149,100 @@ object WidgetArt {
|
|||||||
}
|
}
|
||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flight's route as an arc: dashed ahead, solid where it has flown, a dot
|
||||||
|
* at either airport and an airliner riding the curve at its heading. White
|
||||||
|
* on a translucent track, like the ring; the widget tints nothing, so the
|
||||||
|
* caller passes the flight colour.
|
||||||
|
*/
|
||||||
|
fun flightArc(
|
||||||
|
progress: Float,
|
||||||
|
color: Int,
|
||||||
|
track: Int,
|
||||||
|
width: Int = 600,
|
||||||
|
height: Int = 120,
|
||||||
|
showPlane: Boolean = true,
|
||||||
|
rtl: Boolean = false,
|
||||||
|
): Bitmap {
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
// Right to left the origin stands on the right: the whole drawing is mirrored.
|
||||||
|
if (rtl) canvas.scale(-1f, 1f, width / 2f, height / 2f)
|
||||||
|
val p = progress.coerceIn(0f, 1f)
|
||||||
|
val inset = height * 0.14f
|
||||||
|
val sx = inset
|
||||||
|
val sy = height - inset
|
||||||
|
val ex = width - inset
|
||||||
|
val ey = height - inset
|
||||||
|
val cx = width / 2f
|
||||||
|
val cy = -height * 0.45f
|
||||||
|
val path = android.graphics.Path().apply {
|
||||||
|
moveTo(sx, sy)
|
||||||
|
quadTo(cx, cy, ex, ey)
|
||||||
|
}
|
||||||
|
val line = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeWidth = height * 0.035f
|
||||||
|
this.color = track
|
||||||
|
pathEffect = android.graphics.DashPathEffect(floatArrayOf(height * 0.02f, height * 0.07f), 0f)
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, line)
|
||||||
|
val measure = android.graphics.PathMeasure(path, false)
|
||||||
|
if (p > 0f) {
|
||||||
|
val flown = android.graphics.Path()
|
||||||
|
measure.getSegment(0f, measure.length * p, flown, true)
|
||||||
|
line.pathEffect = null
|
||||||
|
line.strokeWidth = height * 0.045f
|
||||||
|
line.color = color
|
||||||
|
canvas.drawPath(flown, line)
|
||||||
|
}
|
||||||
|
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }
|
||||||
|
canvas.drawCircle(sx, sy, height * 0.05f, dot)
|
||||||
|
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = if (p >= 1f) Paint.Style.FILL else Paint.Style.STROKE
|
||||||
|
strokeWidth = height * 0.03f
|
||||||
|
this.color = color
|
||||||
|
}
|
||||||
|
canvas.drawCircle(ex, ey, height * 0.06f, ring)
|
||||||
|
if (showPlane) {
|
||||||
|
val pos = FloatArray(2)
|
||||||
|
val tan = FloatArray(2)
|
||||||
|
measure.getPosTan(measure.length * p, pos, tan)
|
||||||
|
canvas.save()
|
||||||
|
canvas.translate(pos[0], pos[1])
|
||||||
|
canvas.rotate(Math.toDegrees(kotlin.math.atan2(tan[1].toDouble(), tan[0].toDouble())).toFloat())
|
||||||
|
val scale = height * 0.012f
|
||||||
|
canvas.scale(scale, scale)
|
||||||
|
canvas.drawCircle(0f, 0f, 13f, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = Color.WHITE })
|
||||||
|
canvas.drawPath(airliner(), Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color })
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An airliner seen from above, nose along +x, about 18 units long, centred. */
|
||||||
|
private fun airliner(): android.graphics.Path = android.graphics.Path().apply {
|
||||||
|
moveTo(9f, 0f)
|
||||||
|
cubicTo(9f, -0.9f, 8.3f, -1.3f, 7.4f, -1.3f)
|
||||||
|
lineTo(3.2f, -1.3f)
|
||||||
|
lineTo(-1.6f, -7.6f)
|
||||||
|
lineTo(-3.7f, -7.6f)
|
||||||
|
lineTo(-1.4f, -1.3f)
|
||||||
|
lineTo(-5.5f, -1.3f)
|
||||||
|
lineTo(-7.2f, -3.7f)
|
||||||
|
lineTo(-8.8f, -3.7f)
|
||||||
|
lineTo(-7.8f, 0f)
|
||||||
|
lineTo(-8.8f, 3.7f)
|
||||||
|
lineTo(-7.2f, 3.7f)
|
||||||
|
lineTo(-5.5f, 1.3f)
|
||||||
|
lineTo(-1.4f, 1.3f)
|
||||||
|
lineTo(-3.7f, 7.6f)
|
||||||
|
lineTo(-1.6f, 7.6f)
|
||||||
|
lineTo(3.2f, 1.3f)
|
||||||
|
lineTo(7.4f, 1.3f)
|
||||||
|
cubicTo(8.3f, 1.3f, 9f, 0.9f, 9f, 0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ import org.json.JSONObject
|
|||||||
* (WidgetSnapshot.sample in TrekFamily.swift).
|
* (WidgetSnapshot.sample in TrekFamily.swift).
|
||||||
*/
|
*/
|
||||||
object WidgetSamples {
|
object WidgetSamples {
|
||||||
|
/** In the air on a running trip, two days ahead before one: the same LH 716 as the iOS sample. */
|
||||||
|
private fun flightJson(running: Boolean): String {
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
val dep = if (running) now - 4 * 3600 else now + 2 * 86_400 + 5 * 3600
|
||||||
|
val live = if (running) {
|
||||||
|
""""depActual": ${dep + 18 * 60}, "arrExpected": ${dep + 12 * 3600 + 41 * 60}, "arrTimeExpected": "08:46", "delay": 16, "aircraft": "A359 D-AIXA","""
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
return """{"number": "LH 716", "from": "FRA", "to": "HND", "fromCity": "Frankfurt", "toCity": "Tokyo Haneda",
|
||||||
|
"dep": $dep, "arr": ${dep + 12 * 3600 + 25 * 60}, $live "depTime": "13:05", "arrTime": "08:30", "path": "/trips/1?booking=1"}"""
|
||||||
|
}
|
||||||
|
|
||||||
fun emptyText(context: Context): String = context.getString(R.string.widget_empty)
|
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 snapshot(running: Boolean, now: LocalDate = LocalDate.now()): WidgetSnapshot = WidgetSnapshot.fromJson(JSONObject(json(running, now)))
|
||||||
@@ -37,6 +50,7 @@ object WidgetSamples {
|
|||||||
"place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3},
|
"place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3},
|
||||||
$today
|
$today
|
||||||
"weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10},
|
"weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10},
|
||||||
|
"flight": ${flightJson(running)},
|
||||||
"bookings": [
|
"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": "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": "Shinkansen Nozomi 21", "kind": "train", "date": "${iso(if (running) 0 else 13)}", "time": "09:12", "detail": "Tokyo → Kyoto", "confirmed": true, "path": "/trips/1"},
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ data class WidgetSnapshot(
|
|||||||
val atlas: Atlas?,
|
val atlas: Atlas?,
|
||||||
val collab: Collab?,
|
val collab: Collab?,
|
||||||
val labels: Labels,
|
val labels: Labels,
|
||||||
|
/** The trip's next flight or the one in the air; last and defaulted, so older callers keep compiling. */
|
||||||
|
val flight: Flight? = null,
|
||||||
) {
|
) {
|
||||||
data class Trip(
|
data class Trip(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -105,6 +107,39 @@ data class WidgetSnapshot(
|
|||||||
|
|
||||||
data class Collab(val members: List<Member>, val messages: List<Message>, val poll: Poll?, val path: String)
|
data class Collab(val members: List<Member>, val messages: List<Message>, val poll: Poll?, val path: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flight widget's flight. Times are epoch seconds, so the widget moves on
|
||||||
|
* with the clock between two snapshots; the clock times at either airport
|
||||||
|
* come formatted, since the widget has no zone data.
|
||||||
|
*/
|
||||||
|
data class Flight(
|
||||||
|
val number: String,
|
||||||
|
val from: String,
|
||||||
|
val to: String,
|
||||||
|
val fromCity: String?,
|
||||||
|
val toCity: String?,
|
||||||
|
val dep: Long,
|
||||||
|
val arr: Long,
|
||||||
|
/** Observed take-off. */
|
||||||
|
val depActual: Long?,
|
||||||
|
/** Before take-off: the planned departure plus the delay seen so far. */
|
||||||
|
val depExpected: Long?,
|
||||||
|
/** The feed saw the aircraft in the air when the app last looked. */
|
||||||
|
val airborne: Boolean,
|
||||||
|
/** Touchdown once landed, else the estimate while in the air. */
|
||||||
|
val arrExpected: Long?,
|
||||||
|
val landed: Boolean,
|
||||||
|
val depTime: String,
|
||||||
|
val arrTime: String,
|
||||||
|
val arrTimeExpected: String?,
|
||||||
|
/** Minutes late against the schedule, negative when early. */
|
||||||
|
val delay: Int?,
|
||||||
|
val aircraft: String?,
|
||||||
|
/** When the feed last heard the aircraft; null for a flight it never heard of. */
|
||||||
|
val seen: Long?,
|
||||||
|
val path: String,
|
||||||
|
)
|
||||||
|
|
||||||
/** The labels, translated by the app. The optional ones fall back to English. */
|
/** The labels, translated by the app. The optional ones fall back to English. */
|
||||||
data class Labels(private val raw: JSONObject) {
|
data class Labels(private val raw: JSONObject) {
|
||||||
val nextTrip get() = text("nextTrip", "Next trip")
|
val nextTrip get() = text("nextTrip", "Next trip")
|
||||||
@@ -137,6 +172,23 @@ data class WidgetSnapshot(
|
|||||||
val votes get() = text("votes", "votes")
|
val votes get() = text("votes", "votes")
|
||||||
val noMessages get() = text("noMessages", "No messages yet")
|
val noMessages get() = text("noMessages", "No messages yet")
|
||||||
val departure get() = text("departure", "Departure")
|
val departure get() = text("departure", "Departure")
|
||||||
|
val flight get() = text("flight", "Flight")
|
||||||
|
/** Contains "{time}". */
|
||||||
|
val flightDeparts get() = text("flightDeparts", "Departs in {time}")
|
||||||
|
/** Contains "{time}". */
|
||||||
|
val flightLands get() = text("flightLands", "Lands in {time}")
|
||||||
|
val flightLanded get() = text("flightLanded", "Landed")
|
||||||
|
val flightReady get() = text("flightReady", "Ready for departure")
|
||||||
|
val noFlight get() = text("noFlight", "No flight ahead")
|
||||||
|
val flightOnTime get() = text("flightOnTime", "On time")
|
||||||
|
/** Contains "{m}". */
|
||||||
|
val flightLate get() = text("flightLate", "+{m} min")
|
||||||
|
/** Contains "{m}". */
|
||||||
|
val flightEarly get() = text("flightEarly", "{m} min early")
|
||||||
|
/** Contain "{d}" and "{h}", "{h}" and "{m}", and "{m}". */
|
||||||
|
val durDaysHours get() = text("durDaysHours", "{d} d {h} h")
|
||||||
|
val durHoursMinutes get() = text("durHoursMinutes", "{h} h {m} min")
|
||||||
|
val durMinutes get() = text("durMinutes", "{m} min")
|
||||||
|
|
||||||
private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback }
|
private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback }
|
||||||
}
|
}
|
||||||
@@ -240,6 +292,29 @@ data class WidgetSnapshot(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
labels = Labels(o.optJSONObject("labels") ?: JSONObject()),
|
labels = Labels(o.optJSONObject("labels") ?: JSONObject()),
|
||||||
|
flight = o.optJSONObject("flight")?.let { f ->
|
||||||
|
Flight(
|
||||||
|
number = f.optString("number"),
|
||||||
|
from = f.optString("from"),
|
||||||
|
to = f.optString("to"),
|
||||||
|
fromCity = f.str("fromCity"),
|
||||||
|
toCity = f.str("toCity"),
|
||||||
|
dep = f.optLong("dep"),
|
||||||
|
arr = f.optLong("arr"),
|
||||||
|
depActual = f.long("depActual"),
|
||||||
|
depExpected = f.long("depExpected"),
|
||||||
|
airborne = f.optBoolean("airborne"),
|
||||||
|
arrExpected = f.long("arrExpected"),
|
||||||
|
landed = f.optBoolean("landed"),
|
||||||
|
depTime = f.optString("depTime"),
|
||||||
|
arrTime = f.optString("arrTime"),
|
||||||
|
arrTimeExpected = f.str("arrTimeExpected"),
|
||||||
|
delay = f.int("delay"),
|
||||||
|
aircraft = f.str("aircraft"),
|
||||||
|
seen = f.long("seen"),
|
||||||
|
path = f.optString("path", "/"),
|
||||||
|
)
|
||||||
|
}?.takeIf { it.dep > 0 && it.arr > 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
fun day(iso: String?): LocalDate? {
|
fun day(iso: String?): LocalDate? {
|
||||||
@@ -258,6 +333,8 @@ data class WidgetSnapshot(
|
|||||||
|
|
||||||
private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
|
private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
|
||||||
|
|
||||||
|
private fun JSONObject.long(key: String): Long? = if (has(key) && !isNull(key)) optLong(key) else null
|
||||||
|
|
||||||
private fun JSONArray?.objects(): List<JSONObject> =
|
private fun JSONArray?.objects(): List<JSONObject> =
|
||||||
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
|
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">خطة اليوم كخط زمني، مع الطقس والمحطة التالية.</string>
|
<string name="widget_today_description">خطة اليوم كخط زمني، مع الطقس والمحطة التالية.</string>
|
||||||
<string name="widget_bookings_name">الحجوزات</string>
|
<string name="widget_bookings_name">الحجوزات</string>
|
||||||
<string name="widget_bookings_description">رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.</string>
|
<string name="widget_bookings_description">رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.</string>
|
||||||
|
<string name="widget_flight_name">الرحلة</string>
|
||||||
|
<string name="widget_flight_description">رحلتك القادمة مباشرة: عد تنازلي للإقلاع، والطائرة في طريقها، والتأخير والوصول.</string>
|
||||||
<string name="widget_todos_name">المهام</string>
|
<string name="widget_todos_name">المهام</string>
|
||||||
<string name="widget_todos_description">ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.</string>
|
<string name="widget_todos_description">ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.</string>
|
||||||
<string name="widget_packing_name">قائمة الأمتعة</string>
|
<string name="widget_packing_name">قائمة الأمتعة</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.</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_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_bookings_description">Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.</string>
|
||||||
|
<string name="widget_flight_name">Uçuş</string>
|
||||||
|
<string name="widget_flight_description">Növbəti uçuşun canlı: uçuşa qədər geri sayım, yolda olan təyyarə, gecikmə və gəliş.</string>
|
||||||
<string name="widget_todos_name">Tapşırıqlar</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_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_name">Yük siyahısı</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">以时间线显示今天的计划,附带天气和下一站。</string>
|
<string name="widget_today_description">以时间线显示今天的计划,附带天气和下一站。</string>
|
||||||
<string name="widget_bookings_name">预订</string>
|
<string name="widget_bookings_name">预订</string>
|
||||||
<string name="widget_bookings_description">你接下来的航班、火车、酒店和餐厅,以及时间。</string>
|
<string name="widget_bookings_description">你接下来的航班、火车、酒店和餐厅,以及时间。</string>
|
||||||
|
<string name="widget_flight_name">航班</string>
|
||||||
|
<string name="widget_flight_description">实时查看下一班航班:起飞倒计时、飞行中的飞机、延误和到达。</string>
|
||||||
<string name="widget_todos_name">待办</string>
|
<string name="widget_todos_name">待办</string>
|
||||||
<string name="widget_todos_description">旅行还剩哪些事没做,以及行李收拾到哪一步。</string>
|
<string name="widget_todos_description">旅行还剩哪些事没做,以及行李收拾到哪一步。</string>
|
||||||
<string name="widget_packing_name">行李</string>
|
<string name="widget_packing_name">行李</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">以時間軸顯示今天的計畫,附上天氣與下一站。</string>
|
<string name="widget_today_description">以時間軸顯示今天的計畫,附上天氣與下一站。</string>
|
||||||
<string name="widget_bookings_name">預訂</string>
|
<string name="widget_bookings_name">預訂</string>
|
||||||
<string name="widget_bookings_description">你接下來的航班、火車、飯店與餐廳,以及時間。</string>
|
<string name="widget_bookings_description">你接下來的航班、火車、飯店與餐廳,以及時間。</string>
|
||||||
|
<string name="widget_flight_name">航班</string>
|
||||||
|
<string name="widget_flight_description">即時查看下一班航班:起飛倒數、飛行中的飛機、延誤和抵達。</string>
|
||||||
<string name="widget_todos_name">待辦</string>
|
<string name="widget_todos_name">待辦</string>
|
||||||
<string name="widget_todos_description">旅行還剩哪些事沒做,以及行李整理到哪一步。</string>
|
<string name="widget_todos_description">旅行還剩哪些事沒做,以及行李整理到哪一步。</string>
|
||||||
<string name="widget_packing_name">行李</string>
|
<string name="widget_packing_name">行李</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<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_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_name">Reserves</string>
|
||||||
<string name="widget_bookings_description">Els teus propers vols, trens, hotels i taules, i quan toquen.</string>
|
<string name="widget_bookings_description">Els teus propers vols, trens, hotels i taules, i quan toquen.</string>
|
||||||
|
<string name="widget_flight_name">Vol</string>
|
||||||
|
<string name="widget_flight_description">El teu proper vol en directe: compte enrere de la sortida, l’avió en camí, retard i arribada.</string>
|
||||||
<string name="widget_todos_name">Tasques</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_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_name">Equipatge</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Plán dne jako časová osa, s počasím a další zastávkou.</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_name">Rezervace</string>
|
||||||
<string name="widget_bookings_description">Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.</string>
|
<string name="widget_bookings_description">Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.</string>
|
||||||
|
<string name="widget_flight_name">Let</string>
|
||||||
|
<string name="widget_flight_description">Tvůj další let živě: odpočet do odletu, letadlo na cestě, zpoždění a přílet.</string>
|
||||||
<string name="widget_todos_name">Úkoly</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_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_name">Balení</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.</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_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_bookings_description">Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.</string>
|
||||||
|
<string name="widget_flight_name">Flug</string>
|
||||||
|
<string name="widget_flight_description">Dein nächster Flug live: Countdown bis zum Abflug, die Maschine unterwegs, Verspätung und Ankunft.</string>
|
||||||
<string name="widget_todos_name">To-dos</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_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_name">Packliste</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.</string>
|
<string name="widget_today_description">Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.</string>
|
||||||
<string name="widget_bookings_name">Κρατήσεις</string>
|
<string name="widget_bookings_name">Κρατήσεις</string>
|
||||||
<string name="widget_bookings_description">Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.</string>
|
<string name="widget_bookings_description">Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.</string>
|
||||||
|
<string name="widget_flight_name">Πτήση</string>
|
||||||
|
<string name="widget_flight_description">Η επόμενη πτήση σου ζωντανά: αντίστροφη μέτρηση για την αναχώρηση, το αεροσκάφος καθ’ οδόν, καθυστέρηση και άφιξη.</string>
|
||||||
<string name="widget_todos_name">Εκκρεμότητες</string>
|
<string name="widget_todos_name">Εκκρεμότητες</string>
|
||||||
<string name="widget_todos_description">Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.</string>
|
<string name="widget_todos_description">Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.</string>
|
||||||
<string name="widget_packing_name">Αποσκευές</string>
|
<string name="widget_packing_name">Αποσκευές</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<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_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_name">Reservas</string>
|
||||||
<string name="widget_bookings_description">Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.</string>
|
<string name="widget_bookings_description">Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.</string>
|
||||||
|
<string name="widget_flight_name">Vuelo</string>
|
||||||
|
<string name="widget_flight_description">Tu próximo vuelo en directo: cuenta atrás de la salida, el avión en camino, retraso y llegada.</string>
|
||||||
<string name="widget_todos_name">Tareas</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_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_name">Equipaje</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.</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_name">Broneeringud</string>
|
||||||
<string name="widget_bookings_description">Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.</string>
|
<string name="widget_bookings_description">Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.</string>
|
||||||
|
<string name="widget_flight_name">Lend</string>
|
||||||
|
<string name="widget_flight_description">Sinu järgmine lend otse: loendus väljumiseni, lennuk teel, hilinemine ja saabumine.</string>
|
||||||
<string name="widget_todos_name">Ülesanded</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_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_name">Pakkimine</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Le programme du jour en frise, avec la météo et la prochaine étape.</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_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_bookings_description">Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.</string>
|
||||||
|
<string name="widget_flight_name">Vol</string>
|
||||||
|
<string name="widget_flight_description">Votre prochain vol en direct : compte à rebours du départ, l’avion en route, retard et arrivée.</string>
|
||||||
<string name="widget_todos_name">À faire</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_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_name">Bagages</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<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_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_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_bookings_description">A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.</string>
|
||||||
|
<string name="widget_flight_name">Járat</string>
|
||||||
|
<string name="widget_flight_description">A következő járatod élőben: visszaszámlálás az indulásig, a gép útközben, késés és érkezés.</string>
|
||||||
<string name="widget_todos_name">Teendők</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_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_name">Csomagolás</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.</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_name">Pemesanan</string>
|
||||||
<string name="widget_bookings_description">Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.</string>
|
<string name="widget_bookings_description">Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.</string>
|
||||||
|
<string name="widget_flight_name">Penerbangan</string>
|
||||||
|
<string name="widget_flight_description">Penerbangan berikutnya secara langsung: hitung mundur keberangkatan, pesawat dalam perjalanan, keterlambatan, dan kedatangan.</string>
|
||||||
<string name="widget_todos_name">Tugas</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_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_name">Barang bawaan</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.</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_name">Prenotazioni</string>
|
||||||
<string name="widget_bookings_description">I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.</string>
|
<string name="widget_bookings_description">I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.</string>
|
||||||
|
<string name="widget_flight_name">Volo</string>
|
||||||
|
<string name="widget_flight_description">Il tuo prossimo volo in tempo reale: conto alla rovescia per la partenza, l’aereo in viaggio, ritardo e arrivo.</string>
|
||||||
<string name="widget_todos_name">Da fare</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_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_name">Bagagli</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.</string>
|
<string name="widget_today_description">תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.</string>
|
||||||
<string name="widget_bookings_name">הזמנות</string>
|
<string name="widget_bookings_name">הזמנות</string>
|
||||||
<string name="widget_bookings_description">הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.</string>
|
<string name="widget_bookings_description">הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.</string>
|
||||||
|
<string name="widget_flight_name">טיסה</string>
|
||||||
|
<string name="widget_flight_description">הטיסה הבאה שלך בזמן אמת: ספירה לאחור עד ההמראה, המטוס בדרך, עיכוב והגעה.</string>
|
||||||
<string name="widget_todos_name">משימות</string>
|
<string name="widget_todos_name">משימות</string>
|
||||||
<string name="widget_todos_description">מה עוד פתוח לטיול וכמה התקדמה האריזה.</string>
|
<string name="widget_todos_description">מה עוד פתוח לטיול וכמה התקדמה האריזה.</string>
|
||||||
<string name="widget_packing_name">אריזה</string>
|
<string name="widget_packing_name">אריזה</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">今日の予定をタイムラインで、天気と次の予定とともに表示します。</string>
|
<string name="widget_today_description">今日の予定をタイムラインで、天気と次の予定とともに表示します。</string>
|
||||||
<string name="widget_bookings_name">予約</string>
|
<string name="widget_bookings_name">予約</string>
|
||||||
<string name="widget_bookings_description">次のフライト、列車、ホテル、レストランとその日時。</string>
|
<string name="widget_bookings_description">次のフライト、列車、ホテル、レストランとその日時。</string>
|
||||||
|
<string name="widget_flight_name">フライト</string>
|
||||||
|
<string name="widget_flight_description">次のフライトをライブで:出発までのカウントダウン、飛行中の機体、遅延と到着。</string>
|
||||||
<string name="widget_todos_name">やること</string>
|
<string name="widget_todos_name">やること</string>
|
||||||
<string name="widget_todos_description">旅行のために残っていることと、荷造りの進み具合。</string>
|
<string name="widget_todos_description">旅行のために残っていることと、荷造りの進み具合。</string>
|
||||||
<string name="widget_packing_name">持ち物</string>
|
<string name="widget_packing_name">持ち物</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.</string>
|
<string name="widget_today_description">오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.</string>
|
||||||
<string name="widget_bookings_name">예약</string>
|
<string name="widget_bookings_name">예약</string>
|
||||||
<string name="widget_bookings_description">다가오는 항공편, 기차, 호텔, 식당과 그 시간.</string>
|
<string name="widget_bookings_description">다가오는 항공편, 기차, 호텔, 식당과 그 시간.</string>
|
||||||
|
<string name="widget_flight_name">항공편</string>
|
||||||
|
<string name="widget_flight_description">다음 항공편을 실시간으로: 출발 카운트다운, 비행 중인 항공기, 지연과 도착.</string>
|
||||||
<string name="widget_todos_name">할 일</string>
|
<string name="widget_todos_name">할 일</string>
|
||||||
<string name="widget_todos_description">여행을 위해 남은 일과 짐 싸기 진행 상황.</string>
|
<string name="widget_todos_description">여행을 위해 남은 일과 짐 싸기 진행 상황.</string>
|
||||||
<string name="widget_packing_name">짐 싸기</string>
|
<string name="widget_packing_name">짐 싸기</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Het dagplan als tijdlijn, met het weer en de volgende stop.</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_name">Boekingen</string>
|
||||||
<string name="widget_bookings_description">Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.</string>
|
<string name="widget_bookings_description">Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.</string>
|
||||||
|
<string name="widget_flight_name">Vlucht</string>
|
||||||
|
<string name="widget_flight_description">Je volgende vlucht live: aftellen tot vertrek, het toestel onderweg, vertraging en aankomst.</string>
|
||||||
<string name="widget_todos_name">Taken</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_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_name">Bagage</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.</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_name">Rezerwacje</string>
|
||||||
<string name="widget_bookings_description">Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.</string>
|
<string name="widget_bookings_description">Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.</string>
|
||||||
|
<string name="widget_flight_name">Lot</string>
|
||||||
|
<string name="widget_flight_description">Twój następny lot na żywo: odliczanie do odlotu, samolot w drodze, opóźnienie i przylot.</string>
|
||||||
<string name="widget_todos_name">Zadania</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_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_name">Pakowanie</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<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_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_name">Reservas</string>
|
||||||
<string name="widget_bookings_description">Seus próximos voos, trens, hotéis e mesas, e quando acontecem.</string>
|
<string name="widget_bookings_description">Seus próximos voos, trens, hotéis e mesas, e quando acontecem.</string>
|
||||||
|
<string name="widget_flight_name">Voo</string>
|
||||||
|
<string name="widget_flight_description">Seu próximo voo ao vivo: contagem regressiva para a partida, o avião a caminho, atraso e chegada.</string>
|
||||||
<string name="widget_todos_name">Tarefas</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_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_name">Bagagem</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">План дня в виде ленты времени, с погодой и следующей остановкой.</string>
|
<string name="widget_today_description">План дня в виде ленты времени, с погодой и следующей остановкой.</string>
|
||||||
<string name="widget_bookings_name">Бронирования</string>
|
<string name="widget_bookings_name">Бронирования</string>
|
||||||
<string name="widget_bookings_description">Ближайшие рейсы, поезда, отели и столики и когда они.</string>
|
<string name="widget_bookings_description">Ближайшие рейсы, поезда, отели и столики и когда они.</string>
|
||||||
|
<string name="widget_flight_name">Рейс</string>
|
||||||
|
<string name="widget_flight_description">Ваш следующий рейс в реальном времени: отсчёт до вылета, самолёт в пути, задержка и прибытие.</string>
|
||||||
<string name="widget_todos_name">Задачи</string>
|
<string name="widget_todos_name">Задачи</string>
|
||||||
<string name="widget_todos_description">Что ещё осталось сделать к поездке и как идут сборы.</string>
|
<string name="widget_todos_description">Что ещё осталось сделать к поездке и как идут сборы.</string>
|
||||||
<string name="widget_packing_name">Сборы</string>
|
<string name="widget_packing_name">Сборы</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<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_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_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_bookings_description">Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.</string>
|
||||||
|
<string name="widget_flight_name">Let</string>
|
||||||
|
<string name="widget_flight_description">Tvoj ďalší let naživo: odpočet do odletu, lietadlo na ceste, meškanie a prílet.</string>
|
||||||
<string name="widget_todos_name">Úlohy</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_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_name">Balenie</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Dagens plan som tidslinje, med vädret och nästa stopp.</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_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_bookings_description">Dina nästa flyg, tåg, hotell och bord, och när de är.</string>
|
||||||
|
<string name="widget_flight_name">Flyg</string>
|
||||||
|
<string name="widget_flight_description">Ditt nästa flyg live: nedräkning till avgång, planet på väg, försening och ankomst.</string>
|
||||||
<string name="widget_todos_name">Att göra</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_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_name">Packning</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป</string>
|
<string name="widget_today_description">แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป</string>
|
||||||
<string name="widget_bookings_name">การจอง</string>
|
<string name="widget_bookings_name">การจอง</string>
|
||||||
<string name="widget_bookings_description">เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ</string>
|
<string name="widget_bookings_description">เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ</string>
|
||||||
|
<string name="widget_flight_name">เที่ยวบิน</string>
|
||||||
|
<string name="widget_flight_description">เที่ยวบินถัดไปแบบสด: นับถอยหลังถึงเวลาออกเดินทาง เครื่องบินระหว่างทาง ความล่าช้า และเวลาถึง</string>
|
||||||
<string name="widget_todos_name">สิ่งที่ต้องทำ</string>
|
<string name="widget_todos_name">สิ่งที่ต้องทำ</string>
|
||||||
<string name="widget_todos_description">สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า</string>
|
<string name="widget_todos_description">สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า</string>
|
||||||
<string name="widget_packing_name">จัดกระเป๋า</string>
|
<string name="widget_packing_name">จัดกระเป๋า</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.</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_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_bookings_description">Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.</string>
|
||||||
|
<string name="widget_flight_name">Uçuş</string>
|
||||||
|
<string name="widget_flight_description">Bir sonraki uçuşun canlı: kalkışa geri sayım, yoldaki uçak, gecikme ve varış.</string>
|
||||||
<string name="widget_todos_name">Yapılacaklar</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_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_name">Bavul</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">План дня у вигляді стрічки часу, з погодою і наступною зупинкою.</string>
|
<string name="widget_today_description">План дня у вигляді стрічки часу, з погодою і наступною зупинкою.</string>
|
||||||
<string name="widget_bookings_name">Бронювання</string>
|
<string name="widget_bookings_name">Бронювання</string>
|
||||||
<string name="widget_bookings_description">Найближчі рейси, поїзди, готелі й столики та коли вони.</string>
|
<string name="widget_bookings_description">Найближчі рейси, поїзди, готелі й столики та коли вони.</string>
|
||||||
|
<string name="widget_flight_name">Рейс</string>
|
||||||
|
<string name="widget_flight_description">Ваш наступний рейс наживо: відлік до вильоту, літак у дорозі, затримка й прибуття.</string>
|
||||||
<string name="widget_todos_name">Завдання</string>
|
<string name="widget_todos_name">Завдання</string>
|
||||||
<string name="widget_todos_description">Що ще лишилося зробити до подорожі і як просувається пакування.</string>
|
<string name="widget_todos_description">Що ще лишилося зробити до подорожі і як просувається пакування.</string>
|
||||||
<string name="widget_packing_name">Пакування</string>
|
<string name="widget_packing_name">Пакування</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<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_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_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_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_flight_name">Chuyến bay</string>
|
||||||
|
<string name="widget_flight_description">Chuyến bay tiếp theo trực tiếp: đếm ngược giờ khởi hành, máy bay đang bay, độ trễ và giờ đến.</string>
|
||||||
<string name="widget_todos_name">Việc cần làm</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_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_name">Hành lý</string>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string name="widget_today_description">Today\'s plan as a timeline, with the weather and what is next.</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_name">Bookings</string>
|
||||||
<string name="widget_bookings_description">Your next flights, trains, hotels and tables, and when they are due.</string>
|
<string name="widget_bookings_description">Your next flights, trains, hotels and tables, and when they are due.</string>
|
||||||
|
<string name="widget_flight_name">Flight</string>
|
||||||
|
<string name="widget_flight_description">Your next flight live: departure countdown, the aircraft on its way, delay and arrival.</string>
|
||||||
<string name="widget_todos_name">To-dos</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_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_name">Packing</string>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The flight widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before.
|
||||||
|
No previewImage: Glance draws the picker preview from the sample trip (providePreview). -->
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/widget_flight_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="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" />
|
||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,33 @@ import java.time.LocalDate
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/** The native side's own decisions, apart from the views. */
|
/** The native side's own decisions, apart from the views. */
|
||||||
class NativeLogicTest {
|
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
|
@Test
|
||||||
fun pushesLandInTheChannelOfTheirEvent() {
|
fun pushesLandInTheChannelOfTheirEvent() {
|
||||||
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
|
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,9 +2,15 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-action-sheet'
|
||||||
|
project(':capacitor-action-sheet').projectDir = new File('../../node_modules/@capacitor/action-sheet/android')
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-dialog'
|
||||||
|
project(':capacitor-dialog').projectDir = new File('../../node_modules/@capacitor/dialog/android')
|
||||||
|
|
||||||
include ':capacitor-filesystem'
|
include ':capacitor-filesystem'
|
||||||
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
|
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// The chunk group that holds the lucide icons the app loads at startup (vite.config.js).
|
||||||
|
//
|
||||||
|
// Every lucide icon is a module of its own, and the plugin icon lookup
|
||||||
|
// (src/components/shared/PluginIcon.tsx) reaches all of them by name, from the navigation
|
||||||
|
// every page renders. Left to itself the bundler puts each icon that some other chunk
|
||||||
|
// also uses into a chunk of its own, so that neither side holds a copy: 187 files of a few
|
||||||
|
// hundred bytes each, every one a request of its own on a first visit and an entry of its
|
||||||
|
// own in the service worker's precache. In one group they are one file, whose hash changes
|
||||||
|
// only when lucide-react does.
|
||||||
|
//
|
||||||
|
// `tags: ['$initial']` for the reason the other groups carry it: without it the group would
|
||||||
|
// also take icons that today sit behind a lazy route and make them part of the first load.
|
||||||
|
export const ICON_CHUNK = {
|
||||||
|
name: 'vendor-icons',
|
||||||
|
priority: 20,
|
||||||
|
tags: ['$initial'],
|
||||||
|
test: /[\\/]node_modules[\\/]lucide-react[\\/]/,
|
||||||
|
};
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
512499AF65E091B2BBC9A7BB /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */; };
|
512499AF65E091B2BBC9A7BB /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */; };
|
||||||
5480CCC12F42E9605044AB6B /* ShareHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */; };
|
5480CCC12F42E9605044AB6B /* ShareHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */; };
|
||||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||||
|
F11E0C0A9B7D4E2A8C3F5A01 /* FlightContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E0C0A9B7D4E2A8C3F5A02 /* FlightContent.swift */; };
|
||||||
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
|
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
|
||||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||||
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
|
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
|
||||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
|
||||||
|
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
|
||||||
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
|
||||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||||
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
|
||||||
@@ -172,6 +174,7 @@
|
|||||||
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
|
||||||
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
|
||||||
|
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
|
||||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
@@ -303,6 +306,7 @@
|
|||||||
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||||
|
F11E0C0A9B7D4E2A8C3F5A02 /* FlightContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FlightContent.swift; sourceTree = "<group>"; };
|
||||||
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
@@ -420,6 +424,7 @@
|
|||||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||||
50B271D01FEDC1A000F3C39B /* public */,
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
|
||||||
|
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
|
||||||
);
|
);
|
||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -535,6 +540,7 @@
|
|||||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
|
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
|
||||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
|
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
|
||||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
|
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
|
||||||
|
F11E0C0A9B7D4E2A8C3F5A02 /* FlightContent.swift */,
|
||||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
|
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
|
||||||
);
|
);
|
||||||
name = TREKWidgets;
|
name = TREKWidgets;
|
||||||
@@ -823,6 +829,7 @@
|
|||||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
|
||||||
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
|
||||||
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
|
||||||
|
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -845,6 +852,7 @@
|
|||||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
|
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
|
||||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
|
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
|
||||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
|
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
|
||||||
|
F11E0C0A9B7D4E2A8C3F5A01 /* FlightContent.swift in Sources */,
|
||||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */,
|
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>TREK</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
|
// Launched by a quick action on the app icon: held until the UI asks for it.
|
||||||
|
if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) }
|
||||||
|
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
window?.rootViewController = TrekBridgeViewController()
|
window?.rootViewController = TrekBridgeViewController()
|
||||||
@@ -15,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
for context in connectionOptions.urlContexts where context.url.isFileURL {
|
for context in connectionOptions.urlContexts where context.url.isFileURL {
|
||||||
TrekSharedFiles.receive(context.url)
|
TrekSharedFiles.receive(context.url)
|
||||||
}
|
}
|
||||||
|
// Launched by a tap on a widget or a Live Activity: held like a quick action.
|
||||||
|
// Capacitor still hears of the link, the UI ignores it there (feature appLinks).
|
||||||
|
for context in connectionOptions.urlContexts {
|
||||||
|
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
|
||||||
|
}
|
||||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +45,101 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
// com.liketrek.trek://share: the share sheet left an item for the import.
|
// com.liketrek.trek://share: the share sheet left an item for the import.
|
||||||
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
|
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
|
||||||
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
|
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
|
||||||
let others = URLContexts.subtracting(files).subtracting(handoffs)
|
// com.liketrek.trek://navigate/<path>: a widget or Live Activity tap. Through
|
||||||
|
// Capacitor it was an event a page reloading after the system ended its process
|
||||||
|
// never heard, and that page then took the same link for one already followed.
|
||||||
|
let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil }
|
||||||
|
for context in appLinks {
|
||||||
|
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
|
||||||
|
}
|
||||||
|
let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks)
|
||||||
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
|
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A quick action on the app icon while the app was running in the background.
|
||||||
|
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
completionHandler(TrekQuickActions.open(shortcutItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quick actions on the app icon (a long press on it): "New trip" and the
|
||||||
|
/// trip coming up, set by the UI in its language (TrekShell.setQuickActions).
|
||||||
|
/// Each carries the path it opens; a tap goes to the UI as that path, at once
|
||||||
|
/// when it listens, or held for it to take when the tap launched the app.
|
||||||
|
enum TrekQuickActions {
|
||||||
|
private static let pathKey = "path"
|
||||||
|
private static var pending: String?
|
||||||
|
static var onOpen: ((String) -> Void)?
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
let type: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let icon: String
|
||||||
|
let path: String
|
||||||
|
|
||||||
|
init?(raw: JSObject) {
|
||||||
|
guard let type = raw["type"] as? String, let title = raw["title"] as? String,
|
||||||
|
let path = raw["path"] as? String, path.hasPrefix("/") else { return nil }
|
||||||
|
self.type = type
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = raw["subtitle"] as? String
|
||||||
|
self.icon = raw["icon"] as? String ?? ""
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func set(_ items: [Item]) {
|
||||||
|
UIApplication.shared.shortcutItems = items.prefix(4).map { item in
|
||||||
|
UIApplicationShortcutItem(
|
||||||
|
type: "com.liketrek.trek.\(item.type)",
|
||||||
|
localizedTitle: item.title,
|
||||||
|
localizedSubtitle: item.subtitle,
|
||||||
|
icon: icon(item.icon),
|
||||||
|
userInfo: [pathKey: item.path as NSString]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func icon(_ name: String) -> UIApplicationShortcutIcon {
|
||||||
|
switch name {
|
||||||
|
case "add": return UIApplicationShortcutIcon(type: .add)
|
||||||
|
case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase")
|
||||||
|
default: return UIApplicationShortcutIcon(type: .compose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func open(_ item: UIApplicationShortcutItem) -> Bool {
|
||||||
|
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
|
||||||
|
open(path: path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path to open: at once when the UI listens, else held for it to take.
|
||||||
|
/// Widget and Live Activity taps come this way too, so each is followed once.
|
||||||
|
static func open(path: String) {
|
||||||
|
if let onOpen { onOpen(path) } else { pending = path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path in com.liketrek.trek://navigate/<path>, the link widgets and Live Activities open.
|
||||||
|
static func appLinkPath(_ url: URL) -> String? {
|
||||||
|
let prefix = "com.liketrek.trek://navigate"
|
||||||
|
guard url.absoluteString.hasPrefix(prefix) else { return nil }
|
||||||
|
let path = String(url.absoluteString.dropFirst(prefix.count))
|
||||||
|
if path.isEmpty { return "/" }
|
||||||
|
return path.hasPrefix("/") ? path : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The action the app was launched with, once.
|
||||||
|
static func take() -> String? {
|
||||||
|
defer { pending = nil }
|
||||||
|
return pending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The blur over the app while it sits in the app switcher, when the app lock
|
/// The blur over the app while it sits in the app switcher, when the app lock
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import WebKit
|
|||||||
class TrekBridgeViewController: CAPBridgeViewController {
|
class TrekBridgeViewController: CAPBridgeViewController {
|
||||||
override func instanceDescriptor() -> InstanceDescriptor {
|
override func instanceDescriptor() -> InstanceDescriptor {
|
||||||
let descriptor = super.instanceDescriptor()
|
let descriptor = super.instanceDescriptor()
|
||||||
|
TrekServerStore.recheckAfterUpdate()
|
||||||
if let server = TrekServerStore.url {
|
if let server = TrekServerStore.url {
|
||||||
// The trailing slash matters: Capacitor treats every URL that starts
|
// The trailing slash matters: Capacitor treats every URL that starts
|
||||||
// with this string as the app's own, and without it that would take
|
// with this string as the app's own, and without it that would take
|
||||||
@@ -40,16 +41,273 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
|||||||
// reachable" with a server that was there all along.
|
// reachable" with a server that was there all along.
|
||||||
if let webView, let inner = webView.navigationDelegate {
|
if let webView, let inner = webView.navigationDelegate {
|
||||||
let proxy = TrekNavigationProxy(inner: inner)
|
let proxy = TrekNavigationProxy(inner: inner)
|
||||||
|
proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) }
|
||||||
|
proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) }
|
||||||
navigationProxy = proxy
|
navigationProxy = proxy
|
||||||
webView.navigationDelegate = proxy
|
webView.navigationDelegate = proxy
|
||||||
}
|
}
|
||||||
guardBridgeMessages()
|
guardBridgeMessages()
|
||||||
|
watchSignInPages()
|
||||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||||
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
|
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
|
||||||
// the page follows the finger and an open sheet closes first.
|
// the page follows the finger and an open sheet closes first.
|
||||||
webView?.allowsBackForwardNavigationGestures = false
|
webView?.allowsBackForwardNavigationGestures = false
|
||||||
|
// A long press on a link would lift a preview of the page it points to,
|
||||||
|
// which no native list does; the press means "drag" or nothing here.
|
||||||
|
webView?.allowsLinkPreview = false
|
||||||
|
paintBehindWebView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - What shows where the web view does not reach
|
||||||
|
|
||||||
|
/// The UI reports its theme through StatusBar.setStyle (native/nativeApp.ts),
|
||||||
|
/// which lands here; the colour behind the web view follows it.
|
||||||
|
override func setNeedsStatusBarAppearanceUpdate() {
|
||||||
|
super.setNeedsStatusBarAppearanceUpdate()
|
||||||
|
paintBehindWebView()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Some iOS releases (27.0.x) lay the web view out below the status bar
|
||||||
|
/// instead of under it, and the strip above the page then showed this
|
||||||
|
/// view's black. It now carries the top colour of the screen background
|
||||||
|
/// (mobile.css --m-scr), so the strip reads as part of the page on every
|
||||||
|
/// release, and the rubber band at the ends shows the same colour.
|
||||||
|
private func paintBehindWebView() {
|
||||||
|
let dark: Bool
|
||||||
|
switch preferredStatusBarStyle {
|
||||||
|
case .lightContent: dark = true
|
||||||
|
case .darkContent: dark = false
|
||||||
|
default: dark = traitCollection.userInterfaceStyle == .dark
|
||||||
|
}
|
||||||
|
let color = dark
|
||||||
|
? UIColor(red: 0x17 / 255, green: 0x17 / 255, blue: 0x1C / 255, alpha: 1)
|
||||||
|
: UIColor(red: 0xFB / 255, green: 0xFA / 255, blue: 0xF7 / 255, alpha: 1)
|
||||||
|
view.backgroundColor = color
|
||||||
|
webView?.scrollView.backgroundColor = color
|
||||||
|
webView?.underPageBackgroundColor = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The safe area the page keeps clear of
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
publishSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
super.viewSafeAreaInsetsDidChange()
|
||||||
|
publishSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var publishedSafeArea: UIEdgeInsets?
|
||||||
|
|
||||||
|
/// WebKit's env(safe-area-inset-*) is the window's safe area only where the
|
||||||
|
/// web view overlaps it. Where a release lays the web view out below the
|
||||||
|
/// status bar it reports 0, yet a camera in a corner can still reach into
|
||||||
|
/// the page. The part of the window's safe area (and of the status bar) that
|
||||||
|
/// falls inside the web view goes to the page as --trek-native-safe-*, which
|
||||||
|
/// mobile.css takes as a floor under env(), never in its place.
|
||||||
|
func publishSafeArea(force: Bool = false) {
|
||||||
|
guard let webView, let window = view.window else { return }
|
||||||
|
let frame = webView.convert(webView.bounds, to: window)
|
||||||
|
let safe = window.safeAreaInsets
|
||||||
|
let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||||
|
let insets = UIEdgeInsets(
|
||||||
|
top: max(0, max(safe.top, statusBar) - frame.minY),
|
||||||
|
left: max(0, safe.left - frame.minX),
|
||||||
|
bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)),
|
||||||
|
right: max(0, safe.right - (window.bounds.maxX - frame.maxX))
|
||||||
|
)
|
||||||
|
if !force, insets == publishedSafeArea { return }
|
||||||
|
publishedSafeArea = insets
|
||||||
|
let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" }
|
||||||
|
let js = "(function(s){"
|
||||||
|
+ "s.setProperty('--trek-native-safe-top',\(px(insets.top)));"
|
||||||
|
+ "s.setProperty('--trek-native-safe-left',\(px(insets.left)));"
|
||||||
|
+ "s.setProperty('--trek-native-safe-right',\(px(insets.right)));"
|
||||||
|
+ "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));"
|
||||||
|
+ "})(document.documentElement.style)"
|
||||||
|
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - A proxy's sign-in page
|
||||||
|
|
||||||
|
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
|
||||||
|
/// not there, so nothing else leads back to the address screen, and a passkey
|
||||||
|
/// cannot work in a web view for a domain the app is not tied to. A bar says
|
||||||
|
/// so and offers to change the server; the keyboard gets room on that page,
|
||||||
|
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
|
||||||
|
private var signInBar: UIView?
|
||||||
|
private var urlObservation: NSKeyValueObservation?
|
||||||
|
private var insetObservation: NSKeyValueObservation?
|
||||||
|
private var onSignInPage = false
|
||||||
|
/// Whether the main frame shows an error page in place of the server's (a 4xx
|
||||||
|
/// nobody else handled: a proxy's block page, a client certificate it refused).
|
||||||
|
private var onErrorPage = false
|
||||||
|
private var signInHint: UILabel?
|
||||||
|
private var keyboardUp = false
|
||||||
|
/// How far the keyboard reaches into the view while it is up.
|
||||||
|
private var keyboardRoom: CGFloat = 0
|
||||||
|
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
|
||||||
|
private var ownsInset = false
|
||||||
|
private var bodyReset: DispatchWorkItem?
|
||||||
|
|
||||||
|
private func watchSignInPages() {
|
||||||
|
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
|
||||||
|
let signIn = TrekServerStore.isSignInHost(webView.url)
|
||||||
|
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
|
||||||
|
}
|
||||||
|
// The keyboard plugin zeroes the scroll inset on every frame change it
|
||||||
|
// makes, the last one a while after the keyboard is up. On a sign-in
|
||||||
|
// page the inset wanted here is put back each time.
|
||||||
|
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
|
||||||
|
DispatchQueue.main.async { self?.applyInset() }
|
||||||
|
}
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||||
|
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signInPageChanged(_ signIn: Bool) {
|
||||||
|
onSignInPage = signIn
|
||||||
|
if signIn && signInBar == nil { signInBar = makeSignInBar() }
|
||||||
|
updateSignInBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorPageChanged(_ error: Bool) {
|
||||||
|
onErrorPage = error
|
||||||
|
if error && signInBar == nil { signInBar = makeSignInBar() }
|
||||||
|
updateSignInBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out of the way while the keyboard is up: the form needs the room.
|
||||||
|
private func updateSignInBar() {
|
||||||
|
let labels = TrekServerStore.labels
|
||||||
|
signInHint?.text = onSignInPage
|
||||||
|
? labels["signInHint"]
|
||||||
|
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||||
|
: labels["errorPageHint"] ?? "This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
|
||||||
|
signInBar?.isHidden = !((onSignInPage || onErrorPage) && !keyboardUp)
|
||||||
|
applyInset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On a sign-in page the page can scroll past what covers its end: the
|
||||||
|
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
|
||||||
|
private func applyInset() {
|
||||||
|
guard let scrollView = webView?.scrollView else { return }
|
||||||
|
let wanted: CGFloat
|
||||||
|
if onSignInPage || onErrorPage {
|
||||||
|
if keyboardUp {
|
||||||
|
wanted = keyboardRoom
|
||||||
|
} else if let bar = signInBar {
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
wanted = max(0, view.bounds.height - bar.frame.minY)
|
||||||
|
} else {
|
||||||
|
wanted = 0
|
||||||
|
}
|
||||||
|
ownsInset = true
|
||||||
|
} else if ownsInset {
|
||||||
|
wanted = 0
|
||||||
|
ownsInset = false
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
|
||||||
|
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard plugin also sizes the body to the area above the keyboard,
|
||||||
|
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
|
||||||
|
/// body gets its height back once that has run, so the button under the
|
||||||
|
/// keyboard can be scrolled into view through the inset above.
|
||||||
|
@objc private func keyboardWillShow(_ note: Notification) {
|
||||||
|
keyboardUp = true
|
||||||
|
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||||
|
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
|
||||||
|
}
|
||||||
|
bodyReset?.cancel()
|
||||||
|
if onSignInPage {
|
||||||
|
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
|
||||||
|
let reset = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
|
||||||
|
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
|
||||||
|
self.applyInset()
|
||||||
|
}
|
||||||
|
bodyReset = reset
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
|
||||||
|
}
|
||||||
|
updateSignInBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardWillHide() {
|
||||||
|
keyboardUp = false
|
||||||
|
bodyReset?.cancel()
|
||||||
|
updateSignInBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSignInBar() -> UIView {
|
||||||
|
let labels = TrekServerStore.labels
|
||||||
|
// The look of the app's own first screens: a white card with a sand edge,
|
||||||
|
// warm grey text and a dark button, over whatever page the proxy shows.
|
||||||
|
let bar = UIView()
|
||||||
|
bar.backgroundColor = UIColor(white: 1, alpha: 0.97)
|
||||||
|
bar.layer.cornerRadius = 18
|
||||||
|
bar.layer.cornerCurve = .continuous
|
||||||
|
bar.layer.borderWidth = 1
|
||||||
|
bar.layer.borderColor = UIColor(red: 0xEF / 255, green: 0xE4 / 255, blue: 0xD8 / 255, alpha: 1).cgColor
|
||||||
|
bar.layer.shadowColor = UIColor(red: 0x1F / 255, green: 0x1A / 255, blue: 0x17 / 255, alpha: 1).cgColor
|
||||||
|
bar.layer.shadowOpacity = 0.18
|
||||||
|
bar.layer.shadowRadius = 18
|
||||||
|
bar.layer.shadowOffset = CGSize(width: 0, height: 10)
|
||||||
|
bar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let hint = UILabel()
|
||||||
|
hint.text = labels["signInHint"]
|
||||||
|
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
|
||||||
|
hint.textColor = UIColor(red: 0x6B / 255, green: 0x5F / 255, blue: 0x57 / 255, alpha: 1)
|
||||||
|
hint.font = .preferredFont(forTextStyle: .footnote)
|
||||||
|
hint.adjustsFontForContentSizeCategory = true
|
||||||
|
hint.numberOfLines = 0
|
||||||
|
signInHint = hint
|
||||||
|
hint.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
var look = UIButton.Configuration.filled()
|
||||||
|
look.baseBackgroundColor = UIColor(red: 0x11 / 255, green: 0x18 / 255, blue: 0x27 / 255, alpha: 1)
|
||||||
|
look.baseForegroundColor = .white
|
||||||
|
look.cornerStyle = .fixed
|
||||||
|
look.background.cornerRadius = 12
|
||||||
|
look.contentInsets = NSDirectionalEdgeInsets(top: 9, leading: 14, bottom: 9, trailing: 14)
|
||||||
|
let change = UIButton(configuration: look)
|
||||||
|
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
|
||||||
|
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
|
||||||
|
change.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
change.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
change.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|
||||||
|
bar.addSubview(hint)
|
||||||
|
bar.addSubview(change)
|
||||||
|
view.addSubview(bar)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
|
||||||
|
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||||
|
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
|
||||||
|
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
|
||||||
|
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
|
||||||
|
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
|
||||||
|
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
|
||||||
|
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
|
||||||
|
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
|
||||||
|
])
|
||||||
|
return bar
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What resetServer does from the UI: forget the server, back to the address screen.
|
||||||
|
@objc private func changeServerFromSignIn() {
|
||||||
|
TrekServerStore.url = nil
|
||||||
|
view.window?.rootViewController = TrekBridgeViewController()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
|
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
|
||||||
@@ -57,10 +315,10 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
|||||||
|
|
||||||
/// Capacitor answers bridge messages from any page in the web view. With a
|
/// Capacitor answers bridge messages from any page in the web view. With a
|
||||||
/// proxy's sign-in page loading there, its messages are dropped before they
|
/// proxy's sign-in page loading there, its messages are dropped before they
|
||||||
/// reach the plugins.
|
/// reach the plugins. Always in place: a sign-in host can also be learned
|
||||||
|
/// while the app runs (TrekSignInRedirect).
|
||||||
private func guardBridgeMessages() {
|
private func guardBridgeMessages() {
|
||||||
guard !TrekServerStore.signInHosts.isEmpty,
|
guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
||||||
let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
|
|
||||||
let controller = webView?.configuration.userContentController else { return }
|
let controller = webView?.configuration.userContentController else { return }
|
||||||
let guarded = TrekBridgeMessageGuard(inner: handler)
|
let guarded = TrekBridgeMessageGuard(inner: handler)
|
||||||
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
|
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
|
||||||
@@ -154,13 +412,70 @@ enum TrekKeyCommands {
|
|||||||
enum TrekServerStore {
|
enum TrekServerStore {
|
||||||
private static let key = "TrekServerURL"
|
private static let key = "TrekServerURL"
|
||||||
|
|
||||||
|
private static let failureKey = "TrekLastFailure"
|
||||||
|
|
||||||
|
/// Why the server's page did not load ("NSURLErrorDomain -1003"): the
|
||||||
|
/// address screen shows it small under its message, so a report carries
|
||||||
|
/// the cause. Taken once.
|
||||||
|
static func noteFailure(_ error: Error) {
|
||||||
|
let ns = error as NSError
|
||||||
|
UserDefaults.standard.set("\(ns.domain) \(ns.code)", forKey: failureKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func takeFailure() -> String? {
|
||||||
|
let failure = UserDefaults.standard.string(forKey: failureKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: failureKey)
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let previousKey = "TrekPreviousServerURL"
|
||||||
|
|
||||||
|
private static let checkedKey = "TrekServerCheck"
|
||||||
|
|
||||||
|
private static let labelsKey = "TrekShellLabels"
|
||||||
|
|
||||||
|
/// The words of the bar over a proxy's sign-in page, in the user's language,
|
||||||
|
/// as the address screen passed them (setServer's `labels`): the server's UI
|
||||||
|
/// is not there to say them. Empty keeps the English fallback.
|
||||||
|
static var labels: [String: String] {
|
||||||
|
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
|
||||||
|
/// A server remembered under a lower value is checked once more: the first
|
||||||
|
/// launch after an update that raised it puts the address screen back up,
|
||||||
|
/// filled in with that server (previousURL). Raise both together.
|
||||||
|
private static let serverCheck = 3
|
||||||
|
|
||||||
|
/// Runs before the first bridge is built, so a server picked under an older
|
||||||
|
/// minimum is not loaded unchecked.
|
||||||
|
static func recheckAfterUpdate() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
|
||||||
|
defaults.set(serverCheck, forKey: checkedKey)
|
||||||
|
guard let server = url else { return }
|
||||||
|
url = nil
|
||||||
|
defaults.set(server, forKey: previousKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server an update asked to check again, until a server is set.
|
||||||
|
static var previousURL: String? {
|
||||||
|
UserDefaults.standard.string(forKey: previousKey)
|
||||||
|
}
|
||||||
|
|
||||||
private static let signInHostsKey = "TrekServerSignInHosts"
|
private static let signInHostsKey = "TrekServerSignInHosts"
|
||||||
|
|
||||||
/// Setting the server drops the sign-in hosts of the one before it.
|
/// Setting the server drops the sign-in hosts of the one before it.
|
||||||
static var url: String? {
|
static var url: String? {
|
||||||
get { UserDefaults.standard.string(forKey: key) }
|
get { UserDefaults.standard.string(forKey: key) }
|
||||||
set {
|
set {
|
||||||
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
|
if let newValue {
|
||||||
|
UserDefaults.standard.set(newValue, forKey: key)
|
||||||
|
UserDefaults.standard.removeObject(forKey: previousKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: key)
|
||||||
|
}
|
||||||
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
UserDefaults.standard.removeObject(forKey: signInHostsKey)
|
||||||
try? FileManager.default.removeItem(at: offlineCopyURL)
|
try? FileManager.default.removeItem(at: offlineCopyURL)
|
||||||
}
|
}
|
||||||
@@ -182,6 +497,13 @@ enum TrekServerStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
|
||||||
|
static func addSignInHost(_ host: String) {
|
||||||
|
var hosts = signInHosts
|
||||||
|
guard hosts.insert(host.lowercased()).inserted else { return }
|
||||||
|
signInHosts = hosts
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
|
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
|
||||||
static func isSignInHost(_ url: URL?) -> Bool {
|
static func isSignInHost(_ url: URL?) -> Bool {
|
||||||
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
|
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
|
||||||
@@ -191,6 +513,19 @@ enum TrekServerStore {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 (TrekNavigationProxy). Kept in memory only, never on disk.
|
||||||
|
static var pendingWelcome: String?
|
||||||
|
|
||||||
|
/// The welcome page for the pending code, handed out once.
|
||||||
|
static func takeWelcomeURL() -> URL? {
|
||||||
|
guard let code = pendingWelcome, let server = url else { return nil }
|
||||||
|
pendingWelcome = nil
|
||||||
|
let base = server.hasSuffix("/") ? server : server + "/"
|
||||||
|
return URL(string: base + "api/managed/welcome#" + code)
|
||||||
|
}
|
||||||
|
|
||||||
static var offlineCopyURL: URL {
|
static var offlineCopyURL: URL {
|
||||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
return support.appendingPathComponent("offline-start-page.html")
|
return support.appendingPathComponent("offline-start-page.html")
|
||||||
@@ -412,12 +747,48 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// Safari, 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 taps is no redirect, and once a page of the server has
|
||||||
|
/// shown, nothing is learned any more.
|
||||||
|
enum TrekSignInRedirect {
|
||||||
|
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
|
||||||
|
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
|
||||||
|
guard mainFrame, redirect, !serverShown,
|
||||||
|
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
|
||||||
|
let target, let toHost = target.host?.lowercased(),
|
||||||
|
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
|
||||||
|
// Never from https down to plain http: the sign-in would send a password in the clear.
|
||||||
|
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
|
||||||
|
guard toHost != fromHost else { return nil }
|
||||||
|
if let port = target.port { return "\(toHost):\(port)" }
|
||||||
|
return toHost
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` is on the server's host, where the app's own pages come from.
|
||||||
|
static func onServer(server: String?, url: URL?) -> Bool {
|
||||||
|
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
|
||||||
|
return url?.host?.lowercased() == host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||||
/// but the failures of a navigation that another one replaced: a cancelled
|
/// but the failures of a navigation that another one replaced: a cancelled
|
||||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||||
/// are no fault, and Capacitor would answer them with its error page.
|
/// are no fault, and Capacitor would answer them with its error page.
|
||||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||||
private let inner: WKNavigationDelegate
|
private let inner: WKNavigationDelegate
|
||||||
|
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
|
||||||
|
private var serverShown = false
|
||||||
|
/// Told on the main thread whether the main frame now shows an error page in place of the server's.
|
||||||
|
var onErrorPage: ((Bool) -> Void)?
|
||||||
|
/// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one.
|
||||||
|
var onPageShown: (() -> Void)?
|
||||||
|
|
||||||
init(inner: WKNavigationDelegate) {
|
init(inner: WKNavigationDelegate) {
|
||||||
self.inner = inner
|
self.inner = inner
|
||||||
@@ -446,6 +817,18 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
|||||||
decisionHandler(.allow)
|
decisionHandler(.allow)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// WebKit asks again for every server redirect, as a navigation of type .other.
|
||||||
|
if let host = TrekSignInRedirect.learnedHost(
|
||||||
|
server: TrekServerStore.url,
|
||||||
|
target: navigationAction.request.url,
|
||||||
|
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
|
||||||
|
redirect: navigationAction.navigationType == .other,
|
||||||
|
serverShown: serverShown
|
||||||
|
) {
|
||||||
|
TrekServerStore.addSignInHost(host)
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
|
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
|
||||||
guard (inner as AnyObject).responds(to: selector) else {
|
guard (inner as AnyObject).responds(to: selector) else {
|
||||||
decisionHandler(.allow)
|
decisionHandler(.allow)
|
||||||
@@ -454,13 +837,81 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
|||||||
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
|
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After the server's first page, a pending welcome code opens the welcome
|
||||||
|
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
inner.webView?(webView, didFinish: navigation)
|
||||||
|
onPageShown?()
|
||||||
|
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) {
|
||||||
|
serverShown = true
|
||||||
|
// The server's page is up: an earlier failure is no cause any more.
|
||||||
|
_ = TrekServerStore.takeFailure()
|
||||||
|
}
|
||||||
|
if let welcome = TrekServerStore.takeWelcomeURL() {
|
||||||
|
webView.load(URLRequest(url: welcome))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proxy in front of the server asks for a client certificate (mutual TLS):
|
||||||
|
/// the stored one goes to the host and port it was imported for and nowhere
|
||||||
|
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
|
||||||
|
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||||
|
if TrekClientIdentity.answer(challenge, completionHandler) {
|
||||||
|
let space = challenge.protectionSpace
|
||||||
|
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
|
||||||
|
guard let self, let webView else { return }
|
||||||
|
// Capacitor's own answer to a failed load: the address screen (errorPath).
|
||||||
|
TrekServerStore.noteFailure(URLError(.clientCertificateRejected))
|
||||||
|
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
|
||||||
|
guard (inner as AnyObject).responds(to: selector) else {
|
||||||
|
completionHandler(.performDefaultHandling, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proxy that asked the page for a client certificate, got none and
|
||||||
|
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
|
||||||
|
/// app to the address screen, which says so and offers to add one, rather
|
||||||
|
/// than leaving the proxy's error page up. Any other 4xx page is not TREK's,
|
||||||
|
/// which serves its UI for every path (a proxy's block page, a certificate it
|
||||||
|
/// refused): it stays up with the bar and its way out to another server.
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||||
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||||
|
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
|
||||||
|
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
|
||||||
|
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
TrekServerStore.noteFailure(URLError(.clientCertificateRequired))
|
||||||
|
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse {
|
||||||
|
let error = http.statusCode >= 400 && http.statusCode < 500
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.onErrorPage?(error) }
|
||||||
|
}
|
||||||
|
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
|
||||||
|
guard (inner as AnyObject).responds(to: selector) else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inner.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
if Self.isReplaced(error) { return }
|
if Self.isReplaced(error) { return }
|
||||||
|
TrekServerStore.noteFailure(error)
|
||||||
inner.webView?(webView, didFail: navigation, withError: error)
|
inner.webView?(webView, didFail: navigation, withError: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
if Self.isReplaced(error) { return }
|
if Self.isReplaced(error) { return }
|
||||||
|
TrekServerStore.noteFailure(error)
|
||||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// 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. The password is
|
||||||
|
/// never kept; the identity goes into the keychain, this device only, readable
|
||||||
|
/// after the first unlock (so a background refresh can show it too). Only the
|
||||||
|
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
|
||||||
|
/// sign-in page, a map tile provider or liketrek.com never learns of it.
|
||||||
|
enum TrekClientIdentity {
|
||||||
|
private static let label = "trek-client-identity"
|
||||||
|
private static let hostKey = "TrekClientIdentityHost"
|
||||||
|
private static let portKey = "TrekClientIdentityPort"
|
||||||
|
|
||||||
|
enum Failure: String, Error {
|
||||||
|
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last server that asked for a certificate while the app ran, and whether it got one.
|
||||||
|
struct Request {
|
||||||
|
let host: String
|
||||||
|
let port: Int
|
||||||
|
let served: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var _lastRequest: Request?
|
||||||
|
static var lastRequest: Request? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return _lastRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
static func noteRequest(host: String, port: Int, served: Bool) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Reading a file
|
||||||
|
|
||||||
|
/// Opens a PKCS#12 file: the identity and the rest of its chain.
|
||||||
|
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
|
||||||
|
var items: CFArray?
|
||||||
|
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess: break
|
||||||
|
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
|
||||||
|
default: return .failure(.format)
|
||||||
|
}
|
||||||
|
guard let first = (items as? [[String: Any]])?.first,
|
||||||
|
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
|
||||||
|
let identity = value as! SecIdentity
|
||||||
|
var leaf: SecCertificate?
|
||||||
|
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
|
||||||
|
if let validity = validity(of: leaf) {
|
||||||
|
if now > validity.notAfter { return .failure(.expired) }
|
||||||
|
if now < validity.notBefore { return .failure(.notYetValid) }
|
||||||
|
}
|
||||||
|
let leafData = SecCertificateCopyData(leaf) as Data
|
||||||
|
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
|
||||||
|
SecCertificateCopyData($0) as Data != leafData
|
||||||
|
}
|
||||||
|
return .success((identity, chain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Keeping it
|
||||||
|
|
||||||
|
/// Keeps the identity for this host and port, replacing any other.
|
||||||
|
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
|
||||||
|
remove()
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecValueRef as String: identity,
|
||||||
|
kSecAttrLabel as String: label,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||||
|
]
|
||||||
|
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
|
||||||
|
// The chain's intermediates go along so a proxy that wants them gets them.
|
||||||
|
let encoded = chain.map { SecCertificateCopyData($0) as Data }
|
||||||
|
UserDefaults.standard.set(encoded, forKey: chainKey)
|
||||||
|
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
|
||||||
|
UserDefaults.standard.set(port, forKey: portKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let chainKey = "TrekClientIdentityChain"
|
||||||
|
|
||||||
|
static func remove() {
|
||||||
|
TrekClientCertWatch.reset()
|
||||||
|
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
|
||||||
|
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
|
||||||
|
}
|
||||||
|
UserDefaults.standard.removeObject(forKey: hostKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: portKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: chainKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storedIdentity() -> SecIdentity? {
|
||||||
|
var item: CFTypeRef?
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassIdentity,
|
||||||
|
kSecAttrLabel as String: label,
|
||||||
|
kSecReturnRef as String: true,
|
||||||
|
]
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
|
||||||
|
return (item as! SecIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
|
||||||
|
static func matches(host: String?, port: Int) -> Bool {
|
||||||
|
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
|
||||||
|
let storedPort = UserDefaults.standard.integer(forKey: portKey)
|
||||||
|
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credential for a server at host and port, or nil when the stored one is for another.
|
||||||
|
static func credential(forHost host: String, port: Int) -> URLCredential? {
|
||||||
|
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
|
||||||
|
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
|
||||||
|
SecCertificateCreateWithData(nil, $0 as CFData)
|
||||||
|
}
|
||||||
|
// Per session: a replaced certificate takes effect on the next connection.
|
||||||
|
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
|
||||||
|
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
|
||||||
|
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
|
||||||
|
let space = challenge.protectionSpace
|
||||||
|
let credential = credential(forHost: space.host, port: space.port)
|
||||||
|
noteRequest(host: space.host, port: space.port, served: credential != nil)
|
||||||
|
if let credential {
|
||||||
|
completion(.useCredential, credential)
|
||||||
|
} else {
|
||||||
|
// As a browser without a matching certificate: go on without one.
|
||||||
|
completion(.performDefaultHandling, nil)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: What the settings show
|
||||||
|
|
||||||
|
static func info() -> [String: Any]? {
|
||||||
|
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
|
||||||
|
var leaf: SecCertificate?
|
||||||
|
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
|
||||||
|
let port = UserDefaults.standard.integer(forKey: portKey)
|
||||||
|
var result: [String: Any] = [
|
||||||
|
"host": host,
|
||||||
|
"port": port > 0 ? port : 443,
|
||||||
|
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
|
||||||
|
"issuer": issuerName(of: leaf) ?? "",
|
||||||
|
]
|
||||||
|
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Certificate fields
|
||||||
|
|
||||||
|
/// notBefore and notAfter from the DER: iOS has no public API for them.
|
||||||
|
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
|
||||||
|
DER.validity(SecCertificateCopyData(certificate) as Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func issuerName(of certificate: SecCertificate) -> String? {
|
||||||
|
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
|
||||||
|
enum DER {
|
||||||
|
private struct Node {
|
||||||
|
let tag: UInt8
|
||||||
|
let body: Range<Int>
|
||||||
|
let end: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
|
||||||
|
guard offset + 2 <= bytes.count else { return nil }
|
||||||
|
let tag = bytes[offset]
|
||||||
|
var length = Int(bytes[offset + 1])
|
||||||
|
var start = offset + 2
|
||||||
|
if length & 0x80 != 0 {
|
||||||
|
let count = length & 0x7f
|
||||||
|
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
|
||||||
|
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
|
||||||
|
start += count
|
||||||
|
}
|
||||||
|
guard start + length <= bytes.count else { return nil }
|
||||||
|
return Node(tag: tag, body: start..<start + length, end: start + length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
|
||||||
|
var result: [Node] = []
|
||||||
|
var offset = range.lowerBound
|
||||||
|
while offset < range.upperBound, let child = node(bytes, at: offset) {
|
||||||
|
result.append(child)
|
||||||
|
offset = child.end
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
|
||||||
|
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
|
||||||
|
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
|
||||||
|
var fields = children(bytes, in: tbs.body)
|
||||||
|
if fields.first?.tag == 0xa0 { fields.removeFirst() }
|
||||||
|
return fields.count > 3 ? fields : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
|
||||||
|
let bytes = [UInt8](data)
|
||||||
|
guard let fields = tbsFields(bytes) else { return nil }
|
||||||
|
let times = children(bytes, in: fields[3].body)
|
||||||
|
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
|
||||||
|
return (from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
|
||||||
|
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
|
||||||
|
return formatter.date(from: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
|
||||||
|
static func issuerCommonName(_ data: Data) -> String? {
|
||||||
|
let bytes = [UInt8](data)
|
||||||
|
guard let fields = tbsFields(bytes) else { return nil }
|
||||||
|
for set in children(bytes, in: fields[2].body) {
|
||||||
|
for pair in children(bytes, in: set.body) {
|
||||||
|
let parts = children(bytes, in: pair.body)
|
||||||
|
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
|
||||||
|
return String(bytes: bytes[parts[1].body], encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notices when the user's server stops letting the app in for want of a
|
||||||
|
/// client certificate (one removed, expired, or no longer trusted). The page can
|
||||||
|
/// still be the stored offline copy while every request fails, so when the server
|
||||||
|
/// asks for a certificate 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's own 4xx) calls `onRefused`; a server that merely asks and
|
||||||
|
/// lets everyone in answers TREK's 200. At most once a minute.
|
||||||
|
enum TrekClientCertWatch {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var lastCheck = Date.distantPast
|
||||||
|
|
||||||
|
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
|
||||||
|
static func reset() {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
lastCheck = .distantPast
|
||||||
|
}
|
||||||
|
|
||||||
|
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
|
||||||
|
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
|
||||||
|
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
|
||||||
|
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
|
||||||
|
lock.lock()
|
||||||
|
let due = Date().timeIntervalSince(lastCheck) >= 60
|
||||||
|
if due { lastCheck = Date() }
|
||||||
|
lock.unlock()
|
||||||
|
guard due else { return }
|
||||||
|
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
var task: URLSessionDataTask?
|
||||||
|
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
|
||||||
|
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
if asked && (status == 0 || (400..<500).contains(status)) {
|
||||||
|
DispatchQueue.main.async(execute: onRefused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task?.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A URLSession delegate for the app's own requests to the user's server: it
|
||||||
|
/// shows the client certificate where the server asks for it and notes the ask.
|
||||||
|
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
|
static let shared = TrekClientCertSessionDelegate()
|
||||||
|
|
||||||
|
/// The app's session for the user's server (offline copy, watch, address check).
|
||||||
|
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
|
||||||
|
|
||||||
|
/// Per task: whether the server asked, and whether it got a certificate (address check only).
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var asks: [Int: Bool] = [:]
|
||||||
|
|
||||||
|
func ask(for task: URLSessionTask) -> Bool? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return asks.removeValue(forKey: task.taskIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
|
||||||
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||||
|
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
|
||||||
|
self.lock.lock()
|
||||||
|
self.asks[task.taskIdentifier] = credential != nil
|
||||||
|
self.lock.unlock()
|
||||||
|
completionHandler(disposition, credential)
|
||||||
|
}
|
||||||
|
if !handled { completionHandler(.performDefaultHandling, nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file picker and password prompt of an import, in the UI's words.
|
||||||
|
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
|
||||||
|
private let presenter: UIViewController
|
||||||
|
private let labels: [String: String]
|
||||||
|
private let host: String
|
||||||
|
private let port: Int
|
||||||
|
private let done: ([String: Any]) -> Void
|
||||||
|
private var keepAlive: TrekClientCertImporter?
|
||||||
|
|
||||||
|
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
|
||||||
|
self.presenter = presenter
|
||||||
|
self.labels = labels
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.done = done
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
keepAlive = self
|
||||||
|
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
|
||||||
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
|
||||||
|
picker.delegate = self
|
||||||
|
presenter.present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||||
|
finish(["result": "cancelled"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
|
guard let url = urls.first,
|
||||||
|
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
|
||||||
|
let data = try? Data(contentsOf: url) else {
|
||||||
|
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
askPassword(for: data, wrong: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func askPassword(for data: Data, wrong: Bool) {
|
||||||
|
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
|
||||||
|
alert.addTextField { field in
|
||||||
|
field.isSecureTextEntry = true
|
||||||
|
field.placeholder = self.labels["placeholder"]
|
||||||
|
field.textContentType = .password
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
|
||||||
|
self.finish(["result": "cancelled"])
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
|
||||||
|
self.open(data, password: alert.textFields?.first?.text ?? "")
|
||||||
|
})
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func open(_ data: Data, password: String) {
|
||||||
|
switch TrekClientIdentity.read(data, password: password) {
|
||||||
|
case .failure(.password):
|
||||||
|
askPassword(for: data, wrong: true)
|
||||||
|
case .failure(let failure):
|
||||||
|
finish(["result": "failed", "failure": failure.rawValue])
|
||||||
|
case .success(let (identity, chain)):
|
||||||
|
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
|
||||||
|
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result: [String: Any] = ["result": "imported"]
|
||||||
|
if let info = TrekClientIdentity.info() { result["certificate"] = info }
|
||||||
|
finish(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: [String: Any]) {
|
||||||
|
done(result)
|
||||||
|
keepAlive = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import ActivityKit
|
|||||||
#endif
|
#endif
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
import PassKit
|
import PassKit
|
||||||
|
import QuickLook
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@@ -45,10 +46,18 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "storeHeld", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "requestServer", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
/// What this binary offers the server's UI, raised with every native feature
|
/// What this binary offers the server's UI, raised with every native feature
|
||||||
@@ -60,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||||
static var features: [String] {
|
static var features: [String] {
|
||||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"]
|
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"]
|
||||||
if canAddWalletPasses { names.append("wallet") }
|
if canAddWalletPasses { names.append("wallet") }
|
||||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||||
return names
|
return names
|
||||||
@@ -85,6 +94,32 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
TrekKeyCommands.onPress = { [weak self] id in
|
TrekKeyCommands.onPress = { [weak self] id in
|
||||||
self?.notifyListeners("keyCommand", data: ["id": id])
|
self?.notifyListeners("keyCommand", data: ["id": id])
|
||||||
}
|
}
|
||||||
|
// Kept until a listener takes it: a tap while the page reloads reaches the new page.
|
||||||
|
TrekQuickActions.onOpen = { [weak self] path in
|
||||||
|
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick actions on the app icon
|
||||||
|
|
||||||
|
/// The app icon's quick actions, in the UI's language (TrekQuickActions).
|
||||||
|
@objc func setQuickActions(_ call: CAPPluginCall) {
|
||||||
|
let items = (call.getArray("items", JSObject.self) ?? []).compactMap { TrekQuickActions.Item(raw: $0) }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
TrekQuickActions.set(items)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quick action the app was launched with, before the UI was there to hear of it.
|
||||||
|
@objc func takeQuickAction(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let path = TrekQuickActions.take() {
|
||||||
|
call.resolve(["path": path])
|
||||||
|
} else {
|
||||||
|
call.resolve(["path": NSNull()])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hardware keyboard
|
// MARK: - Hardware keyboard
|
||||||
@@ -139,7 +174,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
nextName: nextName,
|
nextName: nextName,
|
||||||
nextTime: call.getString("nextTime"),
|
nextTime: call.getString("nextTime"),
|
||||||
done: call.getInt("done") ?? 0,
|
done: call.getInt("done") ?? 0,
|
||||||
total: call.getInt("total") ?? 0
|
total: call.getInt("total") ?? 0,
|
||||||
|
flight: TrekShellPlugin.activityFlight(call.getObject("flight"))
|
||||||
)
|
)
|
||||||
// Stale at the end of the day, so a forgotten one fades out on its own.
|
// Stale at the end of the day, so a forgotten one fades out on its own.
|
||||||
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
|
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
|
||||||
@@ -159,6 +195,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
call.resolve(["running": false])
|
call.resolve(["running": false])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The day's flight the UI sent along (native/widgets.ts tripDayState), when complete.
|
||||||
|
@available(iOS 16.1, *)
|
||||||
|
static func activityFlight(_ raw: JSObject?) -> TripDayAttributes.Flight? {
|
||||||
|
guard let raw, let number = raw["number"] as? String, let from = raw["from"] as? String, let to = raw["to"] as? String,
|
||||||
|
let dep = (raw["dep"] as? NSNumber)?.doubleValue, let arr = (raw["arr"] as? NSNumber)?.doubleValue, arr > dep,
|
||||||
|
let depTime = raw["depTime"] as? String, let arrTime = raw["arrTime"] as? String else { return nil }
|
||||||
|
return TripDayAttributes.Flight(
|
||||||
|
number: number, from: from, to: to, dep: dep, arr: arr, depTime: depTime, arrTime: arrTime,
|
||||||
|
delay: (raw["delay"] as? NSNumber)?.intValue,
|
||||||
|
airborne: (raw["airborne"] as? Bool) ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func endTripDay(_ call: CAPPluginCall) {
|
@objc func endTripDay(_ call: CAPPluginCall) {
|
||||||
#if canImport(ActivityKit)
|
#if canImport(ActivityKit)
|
||||||
if #available(iOS 16.2, *) {
|
if #available(iOS 16.2, *) {
|
||||||
@@ -315,7 +364,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
private var authSession: ASWebAuthenticationSession?
|
private var authSession: ASWebAuthenticationSession?
|
||||||
|
|
||||||
@objc func getServer(_ call: CAPPluginCall) {
|
@objc func getServer(_ call: CAPPluginCall) {
|
||||||
call.resolve(["url": TrekServerStore.url ?? NSNull()])
|
call.resolve([
|
||||||
|
"url": TrekServerStore.url ?? NSNull(),
|
||||||
|
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
|
||||||
|
"failure": TrekServerStore.takeFailure() ?? NSNull(),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setServer(_ call: CAPPluginCall) {
|
@objc func setServer(_ call: CAPPluginCall) {
|
||||||
@@ -324,9 +377,14 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
TrekServerStore.url = raw
|
TrekServerStore.url = raw
|
||||||
|
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||||
|
if !labels.isEmpty { TrekServerStore.labels = labels }
|
||||||
// After the URL: setting it clears the hosts of the server before.
|
// After the URL: setting it clears the hosts of the server before.
|
||||||
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
|
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
|
||||||
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
|
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
|
||||||
|
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
|
||||||
|
let welcome = call.getString("welcome") ?? ""
|
||||||
|
TrekServerStore.pendingWelcome = welcome.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil ? welcome : nil
|
||||||
call.resolve()
|
call.resolve()
|
||||||
restartBridge()
|
restartBridge()
|
||||||
}
|
}
|
||||||
@@ -399,7 +457,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
}
|
}
|
||||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||||
URLSession.shared.dataTask(with: request) { data, response, _ in
|
// The app's session, so a server behind mutual TLS gets the client certificate.
|
||||||
|
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
|
||||||
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
|
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||||
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
|
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
|
||||||
TrekServerStore.url == raw else {
|
TrekServerStore.url == raw else {
|
||||||
@@ -438,8 +497,123 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Client certificates (mutual TLS)
|
||||||
|
|
||||||
|
extension TrekShellPlugin {
|
||||||
|
/// 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. A failed connection resolves with status 0 instead of rejecting.
|
||||||
|
@objc func requestServer(_ call: CAPPluginCall) {
|
||||||
|
guard let raw = call.getString("url"), let url = URL(string: raw),
|
||||||
|
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
|
||||||
|
call.reject("A http(s) URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
|
||||||
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
||||||
|
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
|
||||||
|
let delegate = TrekClientCertSessionDelegate.shared
|
||||||
|
var task: URLSessionDataTask?
|
||||||
|
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
|
||||||
|
let asked = task.flatMap { delegate.ask(for: $0) }
|
||||||
|
var result: [String: Any] = [
|
||||||
|
"certRequested": asked != nil,
|
||||||
|
"certSent": asked == true,
|
||||||
|
"url": response?.url?.absoluteString ?? raw,
|
||||||
|
]
|
||||||
|
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
|
||||||
|
if let http = response as? HTTPURLResponse {
|
||||||
|
result["status"] = http.statusCode
|
||||||
|
let body = (data ?? Data()).prefix(512 * 1024)
|
||||||
|
result["data"] = String(data: body, encoding: .utf8) ?? ""
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
for (name, value) in http.allHeaderFields {
|
||||||
|
if let name = name as? String, let value = value as? String { headers[name] = value }
|
||||||
|
}
|
||||||
|
result["headers"] = headers
|
||||||
|
} else {
|
||||||
|
result["status"] = 0
|
||||||
|
result["data"] = ""
|
||||||
|
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
|
||||||
|
}
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
task?.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored client certificate's details, and the last server that asked for one.
|
||||||
|
@objc func clientCertificate(_ call: CAPPluginCall) {
|
||||||
|
var result: [String: Any] = [:]
|
||||||
|
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
|
||||||
|
if let asked = TrekClientIdentity.lastRequest {
|
||||||
|
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
|
||||||
|
}
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func removeClientCertificate(_ call: CAPPluginCall) {
|
||||||
|
TrekClientIdentity.remove()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
@objc func importClientCertificate(_ call: CAPPluginCall) {
|
||||||
|
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
|
||||||
|
call.reject("A host is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let port = call.getInt("port") ?? 443
|
||||||
|
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let presenter = self?.bridge?.viewController else {
|
||||||
|
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
|
||||||
|
call.resolve(result)
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||||
/// When-In-Use permission asked the first time.
|
/// When-In-Use permission asked the first time.
|
||||||
|
// MARK: - Quick Look
|
||||||
|
|
||||||
|
extension TrekShellPlugin {
|
||||||
|
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
|
||||||
|
/// One preview at a time, so a static: an extension cannot store per instance.
|
||||||
|
private static var previewSource: TrekFilePreviewSource?
|
||||||
|
|
||||||
|
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
|
||||||
|
/// an image, a document, with the system's own share button. "unsupported" for
|
||||||
|
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
|
||||||
|
@objc func previewFile(_ call: CAPPluginCall) {
|
||||||
|
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
|
||||||
|
FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
call.reject("a file in the app's cache is required", "INVALID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, let presenter = self.bridge?.viewController else {
|
||||||
|
call.reject("nothing to show the preview from", "UNAVAILABLE")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
|
||||||
|
call.resolve(["result": "unsupported"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
|
||||||
|
TrekShellPlugin.previewSource = source
|
||||||
|
let preview = QLPreviewController()
|
||||||
|
preview.dataSource = source
|
||||||
|
presenter.present(preview, animated: true)
|
||||||
|
call.resolve(["result": "shown"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Apple Wallet
|
// MARK: - Apple Wallet
|
||||||
|
|
||||||
extension TrekShellPlugin {
|
extension TrekShellPlugin {
|
||||||
@@ -522,6 +696,17 @@ extension TrekShellPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
|
// One subscription per Apple ID: another plan would change it, not add one.
|
||||||
|
// A held one that was delivered stops the purchase here. One that was paid
|
||||||
|
// but never set up is handed back as bought, so its instance is set up now.
|
||||||
|
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
|
||||||
|
if await TrekStore.awaitingSetup(held) {
|
||||||
|
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
|
||||||
|
} else {
|
||||||
|
call.resolve(["kind": "subscribed"])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
guard let product = try await Product.products(for: [id]).first else {
|
guard let product = try await Product.products(for: [id]).first else {
|
||||||
call.resolve(["kind": "failed"])
|
call.resolve(["kind": "failed"])
|
||||||
@@ -578,6 +763,21 @@ extension TrekShellPlugin {
|
|||||||
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
|
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feature storeKit: whether this Apple ID holds a delivered subscription under
|
||||||
|
/// `prefix`. Read from the entitlements on the device, so no password is asked;
|
||||||
|
/// while one runs, the shell offers no purchase.
|
||||||
|
@objc func storeHeld(_ call: CAPPluginCall) {
|
||||||
|
let prefix = call.getString("prefix") ?? "cloud."
|
||||||
|
Task {
|
||||||
|
guard let held = await TrekStore.currentEntitlement(prefix: prefix) else {
|
||||||
|
call.resolve(["held": false])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let awaiting = await TrekStore.awaitingSetup(held)
|
||||||
|
call.resolve(["held": !awaiting])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TrekStore {
|
enum TrekStore {
|
||||||
@@ -608,17 +808,62 @@ enum TrekStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
|
typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
|
||||||
var newest: (date: Date, jws: String, id: UInt64)?
|
|
||||||
|
/// The subscription this Apple ID holds under `prefix`, newest first. The
|
||||||
|
/// entitlements on the device come first; on a fresh install they can still
|
||||||
|
/// be empty while the subscription runs, so the status of the subscription
|
||||||
|
/// group, which StoreKit asks the App Store for, decides then.
|
||||||
|
static func currentEntitlement(prefix: String) async -> Held? {
|
||||||
|
var newest: (date: Date, held: Held)?
|
||||||
for await result in Transaction.currentEntitlements {
|
for await result in Transaction.currentEntitlements {
|
||||||
guard case .verified(let transaction) = result,
|
guard case .verified(let transaction) = result,
|
||||||
transaction.productID.hasPrefix(prefix),
|
transaction.productID.hasPrefix(prefix),
|
||||||
transaction.revocationDate == nil else { continue }
|
transaction.revocationDate == nil else { continue }
|
||||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
|
newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newest.map { ($0.jws, $0.id) }
|
if let newest { return newest.held }
|
||||||
|
return await subscriptionGroupHeld(prefix: prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
|
||||||
|
/// productIdFor in iap.ts); one of them is enough to find their group.
|
||||||
|
private static let plans = ["start", "basis", "plus", "pro"]
|
||||||
|
|
||||||
|
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
|
||||||
|
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
|
||||||
|
guard let products = try? await Product.products(for: ids),
|
||||||
|
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
|
||||||
|
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
|
||||||
|
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
|
||||||
|
for status in statuses where running.contains(status.state) {
|
||||||
|
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
|
||||||
|
return held(status.transaction.jwsRepresentation, transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
|
||||||
|
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A first purchase that was paid but whose instance was never set up: handed
|
||||||
|
/// back as bought so it is set up now. A renewal the device has not finished
|
||||||
|
/// yet is no such purchase; its subscription is running and counts as held.
|
||||||
|
static func awaitingSetup(_ held: Held) async -> Bool {
|
||||||
|
guard held.firstPurchase else { return false }
|
||||||
|
return await isUnfinished(held.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
|
||||||
|
static func isUnfinished(_ id: UInt64) async -> Bool {
|
||||||
|
for await result in Transaction.unfinished {
|
||||||
|
if case .verified(let transaction) = result, transaction.id == id { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,3 +1087,26 @@ enum TrekSharedKeychain {
|
|||||||
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
|
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one file Quick Look shows, under the name the user knows it by.
|
||||||
|
final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource {
|
||||||
|
private let item: Item
|
||||||
|
|
||||||
|
init(url: URL, title: String?) {
|
||||||
|
item = Item(url: url, title: title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||||
|
|
||||||
|
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item }
|
||||||
|
|
||||||
|
final class Item: NSObject, QLPreviewItem {
|
||||||
|
let previewItemURL: URL?
|
||||||
|
let previewItemTitle: String?
|
||||||
|
|
||||||
|
init(url: URL, title: String?) {
|
||||||
|
previewItemURL = url
|
||||||
|
previewItemTitle = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ enum TrekWatchWriter {
|
|||||||
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
|
||||||
signed.setValue(value, forHTTPHeaderField: field)
|
signed.setValue(value, forHTTPHeaderField: field)
|
||||||
}
|
}
|
||||||
URLSession.shared.dataTask(with: signed) { data, response, error in
|
TrekClientCertSessionDelegate.session.dataTask(with: signed) { data, response, error in
|
||||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
completion(data, error == nil && (200..<300).contains(status))
|
completion(data, error == nil && (200..<300).contains(status))
|
||||||
}.resume()
|
}.resume()
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
|
||||||
|
.package(name: "CapacitorActionSheet", path: "../../../../node_modules/@capacitor/action-sheet"),
|
||||||
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
|
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
|
||||||
|
.package(name: "CapacitorDialog", path: "../../../../node_modules/@capacitor/dialog"),
|
||||||
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
|
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
|
||||||
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
|
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
|
||||||
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
|
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
|
||||||
@@ -26,7 +28,9 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "CapacitorActionSheet", package: "CapacitorActionSheet"),
|
||||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||||
|
.product(name: "CapacitorDialog", package: "CapacitorDialog"),
|
||||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The trip's next flight, or the one in the air: both airports large, the
|
||||||
|
/// route as an arc with the aircraft riding it, the moment in words with a
|
||||||
|
/// countdown the system keeps running, and the delay. Between two snapshots
|
||||||
|
/// the widget moves on with the clock: it takes off at the observed (or
|
||||||
|
/// planned) time, flies along the arc and lands at the expected (or planned)
|
||||||
|
/// arrival.
|
||||||
|
struct FlightContent: View {
|
||||||
|
let snapshot: WidgetSnapshot?
|
||||||
|
let family: TrekFamily
|
||||||
|
let now: Date
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
private let accent = Trek.booking("flight").color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let flight = snapshot?.flight, let labels = snapshot?.labels {
|
||||||
|
let moment = FlightMoment(flight, now: now)
|
||||||
|
switch family {
|
||||||
|
case .circular: circular(flight, moment)
|
||||||
|
case .rectangular: rectangular(flight, moment, labels)
|
||||||
|
case .inline: inline(flight, moment, labels)
|
||||||
|
case .small: small(flight, moment, labels)
|
||||||
|
case .medium: medium(flight, moment, labels)
|
||||||
|
default: large(flight, moment, labels)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
empty(snapshot?.labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Small: the moment above, the route below
|
||||||
|
|
||||||
|
private func small(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(accent)
|
||||||
|
Text(f.number).font(.system(size: 12, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
delayChip(f, labels, compact: true)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
momentText(f, m, labels)
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(m.stage == .airborne ? accent : Trek.ink(scheme))
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(f.from).font(Trek.number(24, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||||
|
Spacer(minLength: 2)
|
||||||
|
Text(f.to).font(Trek.number(24, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||||
|
}
|
||||||
|
RouteArc(progress: m.progress, color: accent).flipsForRightToLeftLayoutDirection(true).frame(height: 20).padding(.vertical, 3)
|
||||||
|
HStack {
|
||||||
|
Text(f.depTime).font(Trek.number(12, .bold)).foregroundStyle(Trek.muted(scheme))
|
||||||
|
Spacer(minLength: 2)
|
||||||
|
Text(f.arrTimeExpected ?? f.arrTime).font(Trek.number(12, .bold)).foregroundStyle(Trek.muted(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Medium: a boarding pass
|
||||||
|
|
||||||
|
private func medium(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Text(labels.flight ?? "").trekEyebrow(scheme)
|
||||||
|
Text(f.number).font(.system(size: 11, weight: .heavy)).foregroundStyle(accent)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
delayChip(f, labels, compact: false)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
airport(f.from, f.fromCity, time: f.depTime, planned: nil, align: .leading)
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
RouteArc(progress: m.progress, color: accent).flipsForRightToLeftLayoutDirection(true).frame(height: 30)
|
||||||
|
progressBar(m)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
airport(f.to, f.toCity, time: f.arrTimeExpected ?? f.arrTime, planned: f.arrTimeExpected.flatMap { $0 == f.arrTime ? nil : f.arrTime }, align: .trailing)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
momentText(f, m, labels)
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(m.stage == .airborne ? accent : Trek.ink(scheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Large and extra large: the pass with the details under it
|
||||||
|
|
||||||
|
private func large(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
medium(f, m, labels).frame(maxHeight: 150)
|
||||||
|
Rectangle().fill(Trek.track(scheme)).frame(height: 0.8)
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], spacing: 8) {
|
||||||
|
if let aircraft = f.aircraft { detail(systemImage: "airplane.circle", value: aircraft) }
|
||||||
|
detail(systemImage: "clock", value: "\(f.depTime) → \(f.arrTimeExpected ?? f.arrTime)")
|
||||||
|
if let fromCity = f.fromCity { detail(systemImage: "airplane.departure", value: fromCity) }
|
||||||
|
if let toCity = f.toCity { detail(systemImage: "airplane.arrival", value: toCity) }
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Lock screen
|
||||||
|
|
||||||
|
private func circular(_ f: WidgetSnapshot.Flight, _ m: FlightMoment) -> some View {
|
||||||
|
ProgressRing(value: m.progress, lineWidth: 5, colors: [.white], trackColor: Color.white.opacity(0.25)) {
|
||||||
|
Image(systemName: m.stage == .landed ? "airplane.arrival" : m.stage == .airborne ? "airplane" : "airplane.departure")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rectangular(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "airplane").font(.system(size: 11, weight: .bold))
|
||||||
|
Text("\(f.number) \(f.from) → \(f.to)").font(.system(size: 12, weight: .heavy)).lineLimit(1)
|
||||||
|
}
|
||||||
|
momentText(f, m, labels).font(.system(size: 13, weight: .semibold)).lineLimit(1)
|
||||||
|
if m.stage == .airborne {
|
||||||
|
ProgressView(timerInterval: m.start...m.end, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inline(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||||
|
Text("\(Image(systemName: "airplane")) \(f.number) ") + momentText(f, m, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Parts
|
||||||
|
|
||||||
|
private func airport(_ code: String, _ city: String?, time: String, planned: String?, align: HorizontalAlignment) -> some View {
|
||||||
|
VStack(alignment: align, spacing: 1) {
|
||||||
|
Text(code).font(Trek.number(28, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||||
|
if let city { Text(city).font(.system(size: 10.5, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
|
||||||
|
if let planned { Text(planned).font(Trek.number(10.5, .bold)).strikethrough().foregroundStyle(Trek.faint(scheme)) }
|
||||||
|
Text(time).font(Trek.number(14, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 96, alignment: align == .leading ? .leading : .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func progressBar(_ m: FlightMoment) -> some View {
|
||||||
|
if m.stage == .airborne {
|
||||||
|
// The system runs this bar on its own, so it fills between two snapshots too.
|
||||||
|
ProgressView(timerInterval: m.start...m.end, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(accent)
|
||||||
|
} else {
|
||||||
|
Capsule().fill(m.stage == .landed ? accent : Trek.track(scheme)).frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detail(systemImage: String, value: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage).font(.system(size: 12, weight: .semibold)).foregroundStyle(accent)
|
||||||
|
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Trek.card(scheme)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Departs in 2 hr, 10 min", "Lands in 1 hr", "Landed": the countdowns are the system's own running text.
|
||||||
|
private func momentText(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> Text {
|
||||||
|
switch m.stage {
|
||||||
|
case .upcoming:
|
||||||
|
return Countdown.text(labels.flightDeparts ?? "{time}", until: m.start)
|
||||||
|
case .ready:
|
||||||
|
return Text(labels.flightReady ?? "")
|
||||||
|
case .airborne:
|
||||||
|
// "In the air" is what the blue line and the moving aircraft say; the words count down to landing.
|
||||||
|
return Countdown.text(labels.flightLands ?? "{time}", until: m.end)
|
||||||
|
case .landed:
|
||||||
|
return Text(labels.flightLanded ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func delayChip(_ f: WidgetSnapshot.Flight, _ labels: WidgetSnapshot.Labels, compact: Bool) -> some View {
|
||||||
|
if let delay = f.delay {
|
||||||
|
let late = delay > 5
|
||||||
|
let early = delay < -5
|
||||||
|
let color = late ? (delay >= 30 ? Trek.danger : Trek.pending) : Trek.confirmed
|
||||||
|
let text = late
|
||||||
|
? (labels.flightLate ?? "+{m}").replacingOccurrences(of: "{m}", with: "\(delay)")
|
||||||
|
: early ? (labels.flightEarly ?? "{m}").replacingOccurrences(of: "{m}", with: "\(-delay)") : (labels.flightOnTime ?? "")
|
||||||
|
if !(compact && !late) {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 10.5, weight: .heavy))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(color.opacity(scheme == .dark ? 0.22 : 0.14)))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(labels?.flight ?? "").trekEyebrow(scheme)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "airplane").font(.system(size: 30, weight: .bold)).foregroundStyle(accent.opacity(0.35))
|
||||||
|
Text(labels?.noFlight ?? labels?.noBookings ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a flight stands at a given moment, from the snapshot's times. What the
|
||||||
|
/// feed saw wins: a flight still at the gate past its planned departure is not
|
||||||
|
/// flown along the arc, and a flight is only landed once confirmed. Only a
|
||||||
|
/// flight the feed never heard of moves on by its schedule alone.
|
||||||
|
struct FlightMoment {
|
||||||
|
enum Stage { case upcoming, ready, airborne, landed }
|
||||||
|
|
||||||
|
let stage: Stage
|
||||||
|
/// 0 at the gate, 1 at the destination.
|
||||||
|
let progress: Double
|
||||||
|
/// Take-off and touchdown the moment is measured between: observed or expected where known, planned otherwise.
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
|
||||||
|
init(_ f: WidgetSnapshot.Flight, now: Date) {
|
||||||
|
let start = Date(timeIntervalSince1970: f.depActual ?? f.depExpected ?? f.dep)
|
||||||
|
let end = max(Date(timeIntervalSince1970: f.arrExpected ?? f.arr), start.addingTimeInterval(60))
|
||||||
|
self.start = start
|
||||||
|
self.end = end
|
||||||
|
let heard = f.seen != nil
|
||||||
|
let flying = f.airborne == true || f.depActual != nil || (!heard && now >= start)
|
||||||
|
// A flight the feed lost stops counting two hours after its arrival.
|
||||||
|
if f.landed == true || (!heard && now >= end) || now >= end.addingTimeInterval(7200) {
|
||||||
|
stage = .landed
|
||||||
|
progress = 1
|
||||||
|
} else if flying {
|
||||||
|
stage = .airborne
|
||||||
|
// Unconfirmed, it never quite arrives on the arc.
|
||||||
|
progress = min(0.98, max(0, now.timeIntervalSince(start) / end.timeIntervalSince(start)))
|
||||||
|
} else if now >= start {
|
||||||
|
stage = .ready
|
||||||
|
progress = 0
|
||||||
|
} else {
|
||||||
|
stage = .upcoming
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sentence with "{time}" in it, the placeholder replaced by a countdown the system keeps running.
|
||||||
|
enum Countdown {
|
||||||
|
static func text(_ sentence: String, until date: Date) -> Text {
|
||||||
|
let parts = sentence.components(separatedBy: "{time}")
|
||||||
|
guard parts.count == 2 else { return Text(sentence) }
|
||||||
|
return Text(parts[0]) + Text(date, style: .relative) + Text(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,30 @@ private struct BookingsFamilyView: View {
|
|||||||
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flight
|
||||||
|
|
||||||
|
struct FlightWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(kind: "TripFlight", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||||
|
TrekContainer(link: entry.snapshot?.flight.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||||
|
FlightFamilyView(entry: entry)
|
||||||
|
} background: {
|
||||||
|
TrekPaper()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.configurationDisplayName("widget.flight.name")
|
||||||
|
.description("widget.flight.description")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlightFamilyView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
let entry: TrekEntry
|
||||||
|
|
||||||
|
var body: some View { FlightContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - To-dos
|
// MARK: - To-dos
|
||||||
|
|
||||||
struct TodosWidget: Widget {
|
struct TodosWidget: Widget {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
/// The TREK widgets: the trip, today's plan, the bookings ahead, the to-dos,
|
/// The TREK widgets: the trip, today's plan, the bookings ahead, the next flight, the to-dos,
|
||||||
/// the packing list, the Atlas and the travel group on the home and lock
|
/// the packing list, the Atlas and the travel group on the home and lock
|
||||||
/// screen, and the travel day as a Live Activity.
|
/// screen, and the travel day as a Live Activity.
|
||||||
@main
|
@main
|
||||||
@@ -10,6 +10,7 @@ struct TREKWidgetsBundle: WidgetBundle {
|
|||||||
NextTripWidget()
|
NextTripWidget()
|
||||||
TodayWidget()
|
TodayWidget()
|
||||||
BookingsWidget()
|
BookingsWidget()
|
||||||
|
FlightWidget()
|
||||||
TodosWidget()
|
TodosWidget()
|
||||||
PackingWidget()
|
PackingWidget()
|
||||||
AtlasWidget()
|
AtlasWidget()
|
||||||
|
|||||||
@@ -115,6 +115,29 @@ extension WidgetSnapshot {
|
|||||||
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
|
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// In the air on a running trip, two days ahead before one.
|
||||||
|
let departure = running ? now.addingTimeInterval(-4 * 3600) : now.addingTimeInterval(2 * 86_400 + 5 * 3600)
|
||||||
|
snapshot.flight = Flight(
|
||||||
|
number: "LH 716", from: "FRA", to: "HND", fromCity: "Frankfurt", toCity: "Tokyo Haneda",
|
||||||
|
dep: departure.timeIntervalSince1970, arr: departure.addingTimeInterval(12 * 3600 + 25 * 60).timeIntervalSince1970,
|
||||||
|
depActual: running ? departure.addingTimeInterval(18 * 60).timeIntervalSince1970 : nil,
|
||||||
|
depExpected: nil, airborne: running ? true : nil,
|
||||||
|
arrExpected: running ? departure.addingTimeInterval(12 * 3600 + 41 * 60).timeIntervalSince1970 : nil,
|
||||||
|
landed: nil, depTime: "13:05", arrTime: "08:30", arrTimeExpected: running ? "08:46" : nil,
|
||||||
|
delay: running ? 16 : nil, aircraft: running ? "A359 D-AIXA" : nil, seen: running ? now.timeIntervalSince1970 : nil,
|
||||||
|
path: "/trips/1?booking=1"
|
||||||
|
)
|
||||||
|
snapshot.labels.flight = "Flight"
|
||||||
|
snapshot.labels.flightDeparts = "Departs in {time}"
|
||||||
|
snapshot.labels.flightInAir = "In the air"
|
||||||
|
snapshot.labels.flightLands = "Lands in {time}"
|
||||||
|
snapshot.labels.flightLanded = "Landed"
|
||||||
|
snapshot.labels.flightOnTime = "On time"
|
||||||
|
snapshot.labels.flightLate = "+{m} min"
|
||||||
|
snapshot.labels.flightEarly = "{m} min early"
|
||||||
|
snapshot.labels.flightEstimated = "estimated"
|
||||||
|
snapshot.labels.flightReady = "Ready for departure"
|
||||||
|
snapshot.labels.noFlight = "No flight ahead"
|
||||||
snapshot.packing?.bags = [
|
snapshot.packing?.bags = [
|
||||||
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
|
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
|
||||||
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
|
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
|
||||||
@@ -174,6 +197,7 @@ extension WidgetSnapshot {
|
|||||||
snapshot.packing = nil
|
snapshot.packing = nil
|
||||||
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
|
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
|
||||||
snapshot.collab = nil
|
snapshot.collab = nil
|
||||||
|
snapshot.flight = nil
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ struct TripDayAttributes: ActivityAttributes {
|
|||||||
/// Stops behind, out of the day's stops.
|
/// Stops behind, out of the day's stops.
|
||||||
var done: Int
|
var done: Int
|
||||||
var total: Int
|
var total: Int
|
||||||
|
/// The day's flight while it is near or in the air, so the activity turns into
|
||||||
|
/// a boarding pass. Optional: a state written by an older app decodes without it.
|
||||||
|
var flight: Flight?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flight the activity follows on its own between two updates: the system
|
||||||
|
/// runs the bar and the countdown from the two times.
|
||||||
|
struct Flight: Codable, Hashable {
|
||||||
|
var number: String
|
||||||
|
var from: String
|
||||||
|
var to: String
|
||||||
|
/// Take-off and touchdown, epoch seconds: observed or expected where known.
|
||||||
|
var dep: Double
|
||||||
|
var arr: Double
|
||||||
|
/// The clock at either airport, formatted by the app.
|
||||||
|
var depTime: String
|
||||||
|
var arrTime: String
|
||||||
|
/// Minutes late, for the colour of the arrival time.
|
||||||
|
var delay: Int?
|
||||||
|
/// In the air when the app last looked: the countdown runs to touchdown.
|
||||||
|
var airborne: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
var tripTitle: String
|
var tripTitle: String
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import WidgetKit
|
|||||||
struct TripDayLiveActivity: Widget {
|
struct TripDayLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
||||||
TripDayBanner(
|
Group {
|
||||||
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
|
if let flight = context.state.flight {
|
||||||
nextName: context.state.nextName, nextTime: context.state.nextTime,
|
FlightBanner(flight: flight, tripTitle: context.attributes.tripTitle)
|
||||||
done: context.state.done, total: context.state.total
|
} else {
|
||||||
)
|
TripDayBanner(
|
||||||
|
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
|
||||||
|
nextName: context.state.nextName, nextTime: context.state.nextTime,
|
||||||
|
done: context.state.done, total: context.state.total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
|
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
|
||||||
.activitySystemActionForegroundColor(.white)
|
.activitySystemActionForegroundColor(.white)
|
||||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||||
@@ -33,17 +39,32 @@ struct TripDayLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if let flight = state.flight {
|
||||||
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
|
FlightPass(flight: flight).padding(.horizontal, 4)
|
||||||
DayStopProgress(done: state.done, total: state.total)
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
|
||||||
|
DayStopProgress(done: state.done, total: state.total)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed)
|
if let flight = state.flight {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "airplane").foregroundStyle(Trek.info)
|
||||||
|
Text(flight.to).font(Trek.number(13, .heavy))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed)
|
||||||
|
}
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
Text(state.nextTime ?? "\(state.done)/\(state.total)")
|
if let flight = state.flight {
|
||||||
.font(Trek.number(13, .heavy))
|
FlightCountdown(flight: flight).font(Trek.number(13, .heavy)).frame(maxWidth: 64)
|
||||||
|
} else {
|
||||||
|
Text(state.nextTime ?? "\(state.done)/\(state.total)")
|
||||||
|
.font(Trek.number(13, .heavy))
|
||||||
|
}
|
||||||
} minimal: {
|
} minimal: {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5)
|
Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5)
|
||||||
@@ -59,3 +80,76 @@ struct TripDayLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The day's flight on the lock screen: both airports, the bar the system runs
|
||||||
|
/// from take-off to touchdown, and a countdown to whichever comes next.
|
||||||
|
struct FlightBanner: View {
|
||||||
|
let flight: TripDayAttributes.Flight
|
||||||
|
let tripTitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(Trek.info)
|
||||||
|
Text(flight.number).font(.system(size: 12, weight: .heavy)).foregroundStyle(.white)
|
||||||
|
Text(tripTitle)
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.tracking(0.5)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
FlightCountdown(flight: flight).font(Trek.number(15, .heavy)).foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
FlightPass(flight: flight)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two airports with their times and the bar between them.
|
||||||
|
struct FlightPass: View {
|
||||||
|
let flight: TripDayAttributes.Flight
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let start = Date(timeIntervalSince1970: flight.dep)
|
||||||
|
let end = Date(timeIntervalSince1970: flight.arr)
|
||||||
|
let late = (flight.delay ?? 0) > 5
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(flight.from).font(Trek.number(26, .heavy)).foregroundStyle(.white)
|
||||||
|
Text(flight.depTime).font(Trek.number(13, .bold)).foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "airplane").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.info)
|
||||||
|
ProgressView(timerInterval: start...end, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(Trek.info)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
|
Text(flight.to).font(Trek.number(26, .heavy)).foregroundStyle(.white)
|
||||||
|
Text(flight.arrTime).font(Trek.number(13, .bold)).foregroundStyle(late ? Trek.pending : .white.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running countdown, in the system's own format: to touchdown once the app saw
|
||||||
|
/// the aircraft in the air, to take-off before. The system runs it between two
|
||||||
|
/// updates; the stage changes with the app's next update after take-off.
|
||||||
|
struct FlightCountdown: View {
|
||||||
|
let flight: TripDayAttributes.Flight
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let start = Date(timeIntervalSince1970: flight.dep)
|
||||||
|
let end = Date(timeIntervalSince1970: flight.arr)
|
||||||
|
let flying = flight.airborne == true
|
||||||
|
let target = flying ? end : start
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: flying ? "airplane.arrival" : "airplane.departure")
|
||||||
|
Text(timerInterval: min(Date(), target)...target, countsDown: true)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,6 +160,38 @@ struct WidgetSnapshot: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The trip's next flight or the one in the air, for the flight widget.
|
||||||
|
/// Times are epoch seconds, so the widget moves its countdown and its
|
||||||
|
/// aircraft on with the clock between two snapshots; the clock times at
|
||||||
|
/// either airport come formatted, the widget has no zone data.
|
||||||
|
struct Flight: Codable {
|
||||||
|
var number: String
|
||||||
|
var from: String
|
||||||
|
var to: String
|
||||||
|
var fromCity: String?
|
||||||
|
var toCity: String?
|
||||||
|
var dep: Double
|
||||||
|
var arr: Double
|
||||||
|
/// Observed take-off.
|
||||||
|
var depActual: Double?
|
||||||
|
/// Before take-off: the planned departure plus the delay seen so far.
|
||||||
|
var depExpected: Double?
|
||||||
|
/// The feed saw the aircraft in the air when the app last looked.
|
||||||
|
var airborne: Bool?
|
||||||
|
/// Touchdown once landed, else the estimate while in the air.
|
||||||
|
var arrExpected: Double?
|
||||||
|
var landed: Bool?
|
||||||
|
var depTime: String
|
||||||
|
var arrTime: String
|
||||||
|
var arrTimeExpected: String?
|
||||||
|
/// Minutes late against the schedule, negative when early.
|
||||||
|
var delay: Int?
|
||||||
|
var aircraft: String?
|
||||||
|
/// When the feed last heard the aircraft.
|
||||||
|
var seen: Double?
|
||||||
|
var path: String
|
||||||
|
}
|
||||||
|
|
||||||
struct Labels: Codable {
|
struct Labels: Codable {
|
||||||
var nextTrip: String
|
var nextTrip: String
|
||||||
var today: String
|
var today: String
|
||||||
@@ -193,6 +225,22 @@ struct WidgetSnapshot: Codable {
|
|||||||
var votes: String?
|
var votes: String?
|
||||||
var noMessages: String?
|
var noMessages: String?
|
||||||
var departure: String?
|
var departure: String?
|
||||||
|
var flight: String?
|
||||||
|
/// Contains "{time}", where the widget puts its running countdown.
|
||||||
|
var flightDeparts: String?
|
||||||
|
var flightInAir: String?
|
||||||
|
/// Contains "{time}".
|
||||||
|
var flightLands: String?
|
||||||
|
var flightLanded: String?
|
||||||
|
var flightOnTime: String?
|
||||||
|
/// Contains "{m}".
|
||||||
|
var flightLate: String?
|
||||||
|
/// Contains "{m}".
|
||||||
|
var flightEarly: String?
|
||||||
|
var flightEstimated: String?
|
||||||
|
/// Past the departure time with the aircraft not yet in the air.
|
||||||
|
var flightReady: String?
|
||||||
|
var noFlight: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
var trip: Trip?
|
var trip: Trip?
|
||||||
@@ -203,6 +251,7 @@ struct WidgetSnapshot: Codable {
|
|||||||
var packing: Packing?
|
var packing: Packing?
|
||||||
var atlas: Atlas?
|
var atlas: Atlas?
|
||||||
var collab: Collab?
|
var collab: Collab?
|
||||||
|
var flight: Flight?
|
||||||
var labels: Labels
|
var labels: Labels
|
||||||
|
|
||||||
/// The dashboard's snapshot.
|
/// The dashboard's snapshot.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية.";
|
"widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية.";
|
||||||
"widget.bookings.name" = "الحجوزات";
|
"widget.bookings.name" = "الحجوزات";
|
||||||
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
|
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
|
||||||
|
"widget.flight.name" = "الرحلة";
|
||||||
|
"widget.flight.description" = "رحلتك القادمة مباشرة: عد تنازلي للإقلاع، والطائرة في طريقها، والتأخير والوصول.";
|
||||||
"widget.todos.name" = "المهام";
|
"widget.todos.name" = "المهام";
|
||||||
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
|
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
|
||||||
"widget.packing.name" = "قائمة الأمتعة";
|
"widget.packing.name" = "قائمة الأمتعة";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.";
|
"widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.";
|
||||||
"widget.bookings.name" = "Bronlar";
|
"widget.bookings.name" = "Bronlar";
|
||||||
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
|
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
|
||||||
|
"widget.flight.name" = "Uçuş";
|
||||||
|
"widget.flight.description" = "Növbəti uçuşun canlı: uçuşa qədər geri sayım, yolda olan təyyarə, gecikmə və gəliş.";
|
||||||
"widget.todos.name" = "Tapşırıqlar";
|
"widget.todos.name" = "Tapşırıqlar";
|
||||||
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
|
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
|
||||||
"widget.packing.name" = "Yük siyahısı";
|
"widget.packing.name" = "Yük siyahısı";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada.";
|
"widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada.";
|
||||||
"widget.bookings.name" = "Reserves";
|
"widget.bookings.name" = "Reserves";
|
||||||
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
|
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
|
||||||
|
"widget.flight.name" = "Vol";
|
||||||
|
"widget.flight.description" = "El teu proper vol en directe: compte enrere de la sortida, l’avió en camí, retard i arribada.";
|
||||||
"widget.todos.name" = "Tasques";
|
"widget.todos.name" = "Tasques";
|
||||||
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
|
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
|
||||||
"widget.packing.name" = "Equipatge";
|
"widget.packing.name" = "Equipatge";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou.";
|
"widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou.";
|
||||||
"widget.bookings.name" = "Rezervace";
|
"widget.bookings.name" = "Rezervace";
|
||||||
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
|
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
|
||||||
|
"widget.flight.name" = "Let";
|
||||||
|
"widget.flight.description" = "Tvůj další let živě: odpočet do odletu, letadlo na cestě, zpoždění a přílet.";
|
||||||
"widget.todos.name" = "Úkoly";
|
"widget.todos.name" = "Úkoly";
|
||||||
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
|
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
|
||||||
"widget.packing.name" = "Balení";
|
"widget.packing.name" = "Balení";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.";
|
"widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.";
|
||||||
"widget.bookings.name" = "Buchungen";
|
"widget.bookings.name" = "Buchungen";
|
||||||
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
|
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
|
||||||
|
"widget.flight.name" = "Flug";
|
||||||
|
"widget.flight.description" = "Dein nächster Flug live: Countdown bis zum Abflug, die Maschine unterwegs, Verspätung und Ankunft.";
|
||||||
"widget.todos.name" = "To-dos";
|
"widget.todos.name" = "To-dos";
|
||||||
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
|
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
|
||||||
"widget.packing.name" = "Packliste";
|
"widget.packing.name" = "Packliste";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.";
|
"widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.";
|
||||||
"widget.bookings.name" = "Κρατήσεις";
|
"widget.bookings.name" = "Κρατήσεις";
|
||||||
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
|
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
|
||||||
|
"widget.flight.name" = "Πτήση";
|
||||||
|
"widget.flight.description" = "Η επόμενη πτήση σου ζωντανά: αντίστροφη μέτρηση για την αναχώρηση, το αεροσκάφος καθ’ οδόν, καθυστέρηση και άφιξη.";
|
||||||
"widget.todos.name" = "Εκκρεμότητες";
|
"widget.todos.name" = "Εκκρεμότητες";
|
||||||
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
|
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
|
||||||
"widget.packing.name" = "Αποσκευές";
|
"widget.packing.name" = "Αποσκευές";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Today's plan as a timeline, with the weather and what is next.";
|
"widget.today.description" = "Today's plan as a timeline, with the weather and what is next.";
|
||||||
"widget.bookings.name" = "Bookings";
|
"widget.bookings.name" = "Bookings";
|
||||||
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
|
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
|
||||||
|
"widget.flight.name" = "Flight";
|
||||||
|
"widget.flight.description" = "Your next flight live: departure countdown, the aircraft on its way, delay and arrival.";
|
||||||
"widget.todos.name" = "To-dos";
|
"widget.todos.name" = "To-dos";
|
||||||
"widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
|
"widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
|
||||||
"widget.packing.name" = "Packing";
|
"widget.packing.name" = "Packing";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada.";
|
"widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada.";
|
||||||
"widget.bookings.name" = "Reservas";
|
"widget.bookings.name" = "Reservas";
|
||||||
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
|
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
|
||||||
|
"widget.flight.name" = "Vuelo";
|
||||||
|
"widget.flight.description" = "Tu próximo vuelo en directo: cuenta atrás de la salida, el avión en camino, retraso y llegada.";
|
||||||
"widget.todos.name" = "Tareas";
|
"widget.todos.name" = "Tareas";
|
||||||
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
|
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
|
||||||
"widget.packing.name" = "Equipaje";
|
"widget.packing.name" = "Equipaje";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.";
|
"widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.";
|
||||||
"widget.bookings.name" = "Broneeringud";
|
"widget.bookings.name" = "Broneeringud";
|
||||||
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
|
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
|
||||||
|
"widget.flight.name" = "Lend";
|
||||||
|
"widget.flight.description" = "Sinu järgmine lend otse: loendus väljumiseni, lennuk teel, hilinemine ja saabumine.";
|
||||||
"widget.todos.name" = "Ülesanded";
|
"widget.todos.name" = "Ülesanded";
|
||||||
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
|
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
|
||||||
"widget.packing.name" = "Pakkimine";
|
"widget.packing.name" = "Pakkimine";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Le programme du jour en frise, avec la météo et la prochaine étape.";
|
"widget.today.description" = "Le programme du jour en frise, avec la météo et la prochaine étape.";
|
||||||
"widget.bookings.name" = "Réservations";
|
"widget.bookings.name" = "Réservations";
|
||||||
"widget.bookings.description" = "Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.";
|
"widget.bookings.description" = "Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.";
|
||||||
|
"widget.flight.name" = "Vol";
|
||||||
|
"widget.flight.description" = "Votre prochain vol en direct : compte à rebours du départ, l’avion en route, retard et arrivée.";
|
||||||
"widget.todos.name" = "À faire";
|
"widget.todos.name" = "À faire";
|
||||||
"widget.todos.description" = "Ce qui reste à faire pour le voyage et où en sont les bagages.";
|
"widget.todos.description" = "Ce qui reste à faire pour le voyage et où en sont les bagages.";
|
||||||
"widget.packing.name" = "Bagages";
|
"widget.packing.name" = "Bagages";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.";
|
"widget.today.description" = "תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.";
|
||||||
"widget.bookings.name" = "הזמנות";
|
"widget.bookings.name" = "הזמנות";
|
||||||
"widget.bookings.description" = "הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.";
|
"widget.bookings.description" = "הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.";
|
||||||
|
"widget.flight.name" = "טיסה";
|
||||||
|
"widget.flight.description" = "הטיסה הבאה שלך בזמן אמת: ספירה לאחור עד ההמראה, המטוס בדרך, עיכוב והגעה.";
|
||||||
"widget.todos.name" = "משימות";
|
"widget.todos.name" = "משימות";
|
||||||
"widget.todos.description" = "מה עוד פתוח לטיול וכמה התקדמה האריזה.";
|
"widget.todos.description" = "מה עוד פתוח לטיול וכמה התקדמה האריזה.";
|
||||||
"widget.packing.name" = "אריזה";
|
"widget.packing.name" = "אריזה";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "A nap terve idővonalon, az időjárással és a következő megállóval.";
|
"widget.today.description" = "A nap terve idővonalon, az időjárással és a következő megállóval.";
|
||||||
"widget.bookings.name" = "Foglalások";
|
"widget.bookings.name" = "Foglalások";
|
||||||
"widget.bookings.description" = "A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.";
|
"widget.bookings.description" = "A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.";
|
||||||
|
"widget.flight.name" = "Járat";
|
||||||
|
"widget.flight.description" = "A következő járatod élőben: visszaszámlálás az indulásig, a gép útközben, késés és érkezés.";
|
||||||
"widget.todos.name" = "Teendők";
|
"widget.todos.name" = "Teendők";
|
||||||
"widget.todos.description" = "Ami még hátravan az utazáshoz, és hol tart a csomagolás.";
|
"widget.todos.description" = "Ami még hátravan az utazáshoz, és hol tart a csomagolás.";
|
||||||
"widget.packing.name" = "Csomagolás";
|
"widget.packing.name" = "Csomagolás";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.";
|
"widget.today.description" = "Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.";
|
||||||
"widget.bookings.name" = "Pemesanan";
|
"widget.bookings.name" = "Pemesanan";
|
||||||
"widget.bookings.description" = "Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.";
|
"widget.bookings.description" = "Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.";
|
||||||
|
"widget.flight.name" = "Penerbangan";
|
||||||
|
"widget.flight.description" = "Penerbangan berikutnya secara langsung: hitung mundur keberangkatan, pesawat dalam perjalanan, keterlambatan, dan kedatangan.";
|
||||||
"widget.todos.name" = "Tugas";
|
"widget.todos.name" = "Tugas";
|
||||||
"widget.todos.description" = "Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.";
|
"widget.todos.description" = "Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.";
|
||||||
"widget.packing.name" = "Barang bawaan";
|
"widget.packing.name" = "Barang bawaan";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.";
|
"widget.today.description" = "Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.";
|
||||||
"widget.bookings.name" = "Prenotazioni";
|
"widget.bookings.name" = "Prenotazioni";
|
||||||
"widget.bookings.description" = "I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.";
|
"widget.bookings.description" = "I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.";
|
||||||
|
"widget.flight.name" = "Volo";
|
||||||
|
"widget.flight.description" = "Il tuo prossimo volo in tempo reale: conto alla rovescia per la partenza, l’aereo in viaggio, ritardo e arrivo.";
|
||||||
"widget.todos.name" = "Da fare";
|
"widget.todos.name" = "Da fare";
|
||||||
"widget.todos.description" = "Cosa manca ancora per il viaggio e a che punto è la valigia.";
|
"widget.todos.description" = "Cosa manca ancora per il viaggio e a che punto è la valigia.";
|
||||||
"widget.packing.name" = "Bagagli";
|
"widget.packing.name" = "Bagagli";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "今日の予定をタイムラインで、天気と次の予定とともに表示します。";
|
"widget.today.description" = "今日の予定をタイムラインで、天気と次の予定とともに表示します。";
|
||||||
"widget.bookings.name" = "予約";
|
"widget.bookings.name" = "予約";
|
||||||
"widget.bookings.description" = "次のフライト、列車、ホテル、レストランとその日時。";
|
"widget.bookings.description" = "次のフライト、列車、ホテル、レストランとその日時。";
|
||||||
|
"widget.flight.name" = "フライト";
|
||||||
|
"widget.flight.description" = "次のフライトをライブで:出発までのカウントダウン、飛行中の機体、遅延と到着。";
|
||||||
"widget.todos.name" = "やること";
|
"widget.todos.name" = "やること";
|
||||||
"widget.todos.description" = "旅行のために残っていることと、荷造りの進み具合。";
|
"widget.todos.description" = "旅行のために残っていることと、荷造りの進み具合。";
|
||||||
"widget.packing.name" = "持ち物";
|
"widget.packing.name" = "持ち物";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.";
|
"widget.today.description" = "오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.";
|
||||||
"widget.bookings.name" = "예약";
|
"widget.bookings.name" = "예약";
|
||||||
"widget.bookings.description" = "다가오는 항공편, 기차, 호텔, 식당과 그 시간.";
|
"widget.bookings.description" = "다가오는 항공편, 기차, 호텔, 식당과 그 시간.";
|
||||||
|
"widget.flight.name" = "항공편";
|
||||||
|
"widget.flight.description" = "다음 항공편을 실시간으로: 출발 카운트다운, 비행 중인 항공기, 지연과 도착.";
|
||||||
"widget.todos.name" = "할 일";
|
"widget.todos.name" = "할 일";
|
||||||
"widget.todos.description" = "여행을 위해 남은 일과 짐 싸기 진행 상황.";
|
"widget.todos.description" = "여행을 위해 남은 일과 짐 싸기 진행 상황.";
|
||||||
"widget.packing.name" = "짐 싸기";
|
"widget.packing.name" = "짐 싸기";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"widget.today.description" = "Het dagplan als tijdlijn, met het weer en de volgende stop.";
|
"widget.today.description" = "Het dagplan als tijdlijn, met het weer en de volgende stop.";
|
||||||
"widget.bookings.name" = "Boekingen";
|
"widget.bookings.name" = "Boekingen";
|
||||||
"widget.bookings.description" = "Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.";
|
"widget.bookings.description" = "Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.";
|
||||||
|
"widget.flight.name" = "Vlucht";
|
||||||
|
"widget.flight.description" = "Je volgende vlucht live: aftellen tot vertrek, het toestel onderweg, vertraging en aankomst.";
|
||||||
"widget.todos.name" = "Taken";
|
"widget.todos.name" = "Taken";
|
||||||
"widget.todos.description" = "Wat er nog openstaat voor de reis en hoe ver het inpakken is.";
|
"widget.todos.description" = "Wat er nog openstaat voor de reis en hoe ver het inpakken is.";
|
||||||
"widget.packing.name" = "Bagage";
|
"widget.packing.name" = "Bagage";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user