feat(native): the apps load the server's own UI

The apps ship only an address screen now. Once a server is picked, the
native side points the WebView at it: TrekBridgeViewController on iOS and a
config copy with server.url on Android. The UI then comes from that server,
in the version it runs and on its own origin, so the session cookie,
uploads, /ws and plugins work exactly as in a browser and no app release
has to keep pace with the servers out there.

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

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

Plugins the frame does not use are gone (push, camera, geolocation,
calendar, file transfer, in-app browser and more): the WebView covers the
camera and location itself, and push needs a relay before it is worth
shipping. With them go the push entitlement, the calendar usage strings
and the Firebase bits. The iOS scheme falls back to capacitor://, since
Capacitor drops an iosScheme of https, and the arm64 capability replaces
armv7.
This commit is contained in:
Maurice
2026-09-24 12:30:32 +02:00
parent 9832d12751
commit c5765bbf15
79 changed files with 1600 additions and 448 deletions
+1
View File
@@ -35,6 +35,7 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
-12
View File
@@ -10,23 +10,11 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-calendar')
implementation project(':capacitor-camera')
implementation project(':capacitor-file-transfer')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-haptics')
implementation project(':capacitor-inappbrowser')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-toast')
}
+11 -90
View File
@@ -24,16 +24,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links (@capacitor/app appUrlOpen).
<!-- Deep links.
The scheme is @string/custom_url_scheme (com.liketrek.trek).
launchMode is singleTask above, so a link delivers into the
running task via onNewIntent rather than starting a second copy
of the app on top of the first.
This is the return path for native OIDC/OAuth login: the flow
opens in a Custom Tab and the provider redirects back to
com.liketrek.trek://… , which lands here.
This is the return path for SSO and passkey sign-in: TrekShell
opens the server's login page in a Custom Tab, and after the
hand-off it redirects to com.liketrek.trek://auth?code=…, which
lands here and in TrekShellPlugin.handleOnNewIntent.
Not an App Link (autoVerify + https). App Links are verified
against a specific domain via assetlinks.json, and TREK is
@@ -59,101 +60,21 @@
android:resource="@xml/file_paths"></meta-data>
</provider>
<!-- Notification icon, shared by @capacitor/push-notifications (via this
FCM meta-data) and @capacitor/local-notifications (via the smallIcon
option in capacitor.config.ts).
It must be a white silhouette on transparency; the launcher icon is a
full-colour square and Android would render it as a white blob.
notification_icon is generated from public/icons/icon-white.svg by
scripts/generate-native-icons.mjs.
default_notification_channel_id names the channel FCM uses for any
message that does not carry its own. The channel itself still has to
be created at runtime with PushNotifications.createChannel() using
this exact id — Android drops notifications for a channel that was
never created, and FCM silently falls back to its own channel.
No default_notification_color on purpose. TREK's palette is neutral
and user-selectable, so there is no one brand colour to tint with;
left unset, Android tints the small icon with the system accent,
which stays legible in both the light and dark shade. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/notification_icon" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id" />
<!-- Asks Play services to install the backported Android photo picker on
Android 11/12 devices, so @capacitor/camera gets the modern picker
instead of falling back to ACTION_OPEN_DOCUMENT. Disabled by design:
it is a dependency declaration, not a service that runs.
Drop this block if TREK ever ships a Play-services-free build
(F-Droid); the fallback picker keeps working without it. -->
<service
android:name="com.google.android.gms.metadata.ModuleDependencies"
android:enabled="false"
android:exported="false"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
</intent-filter>
<meta-data android:name="photopicker_activity:0:required" android:value="" />
</service>
</application>
<!-- Permissions.
Declared here only where the app itself needs them. Plugins that ship
their own manifest contribute theirs through the merge and are NOT
repeated: @capacitor/haptics (VIBRATE), @capacitor/calendar
(READ_CALENDAR, WRITE_CALENDAR) and @capacitor/local-notifications
(RECEIVE_BOOT_COMPLETED, WAKE_LOCK, SCHEDULE_EXACT_ALARM). -->
<!-- Permissions. Only what the WebView itself needs: the file picker's
camera option and navigator.geolocation for "locate me" on the map,
both granted at runtime through Capacitor's WebChromeClient. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Reachability: connectivity.ts probes the server on an interval, and on
native we want to skip the probe entirely when there is no transport.
@capacitor/network declares this too; kept explicit because
connectivity.ts depends on it directly. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android 13+ gates notifications behind a runtime grant. firebase-messaging
and @capacitor/local-notifications declare this too, but relying on a
transitive manifest merge for a permission the app must request by hand is
a silent-failure waiting to happen — it must be requested via
requestPermissions() before register()/schedule(). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- @capacitor/geolocation. Declares no permissions of its own, so both are
required here. COARSE is listed first because it is the weaker grant and
is enough for "what city am I in" — request FINE only where the map
genuinely needs it. -->
<!-- COARSE is listed first because it is the weaker grant and is enough
for "what city am I in". -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- required="false" on purpose: TREK is a planner, and everything except
"locate me" works without GPS hardware. Marking it required would have
Play hide the app from tablets and other GPS-less devices. -->
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<!-- @capacitor/camera. Not strictly required — the plugin launches the system
camera activity, which carries its own permission — but io.ionic.libs's
inappbrowser transitively declares CAMERA anyway, and once it is declared
Android routes the plugin through the runtime-grant path. Declared here
explicitly so it is a decision rather than something inherited. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Stripped from the merge. io.ionic.libs:ioninappbrowser-android declares
RECORD_AUDIO and MODIFY_AUDIO_SETTINGS so that pages opened in its WebView
mode can use getUserMedia for microphone capture. TREK records no audio
anywhere, and shipping these would put "Microphone" on the store listing
and in the app's permission screen for a capability that does not exist.
Revisit only if a feature genuinely needs mic input in an embedded page. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" tools:node="remove" />
<!-- Deliberately NOT declared: READ/WRITE_EXTERNAL_STORAGE.
@capacitor/camera needs storage permissions only for saveToGallery, and
TREK uploads photos to the server rather than writing them to the device
gallery. Add them the day a feature actually needs them. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -1,5 +1,21 @@
package com.liketrek.trek;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(TrekShellPlugin.class);
// With a server picked, the WebView loads that server instead of the
// bundled address screen, so the UI arrives in the version the server
// runs and on its own origin. BridgeActivity reads `config` while it
// builds the bridge inside super.onCreate().
String server = TrekServerStore.getUrl(this);
if (server != null) {
config = TrekServerStore.configFor(this, server);
}
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,79 @@
package com.liketrek.trek;
import android.content.Context;
import android.content.SharedPreferences;
import com.getcapacitor.CapConfig;
import com.getcapacitor.Logger;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.json.JSONException;
import org.json.JSONObject;
/** The server the user picked, and the Capacitor configuration that loads it. */
final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
private static final String CONFIG_DIR = "trek-server-config";
private TrekServerStore() {}
static String getUrl(Context context) {
return prefs(context).getString(KEY_URL, null);
}
static void setUrl(Context context, String url) {
SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
}
editor.commit();
}
/**
* The bundled capacitor.config.json with server.url set. Capacitor only
* takes the server URL from a config file, so this writes a copy into the
* app's private storage and loads that. Falls back to the bundled config,
* which shows the address screen, if the copy cannot be written.
*/
static CapConfig configFor(Context context, String url) {
try {
JSONObject json = new JSONObject(readAsset(context, "capacitor.config.json"));
JSONObject server = json.optJSONObject("server");
if (server == null) {
server = new JSONObject();
json.put("server", server);
}
server.put("url", url);
File dir = new File(context.getFilesDir(), CONFIG_DIR);
if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir);
try (FileOutputStream out = new FileOutputStream(new File(dir, "capacitor.config.json"))) {
out.write(json.toString().getBytes(StandardCharsets.UTF_8));
}
return CapConfig.loadFromFile(context, dir.getAbsolutePath());
} catch (IOException | JSONException ex) {
Logger.error("TrekShell", "Could not point the app at " + url, ex);
return CapConfig.loadDefault(context);
}
}
private static String readAsset(Context context, String name) throws IOException {
try (InputStream in = context.getAssets().open(name); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read);
return out.toString("UTF-8");
}
}
private static SharedPreferences prefs(Context context) {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
}
@@ -0,0 +1,148 @@
package com.liketrek.trek;
import android.content.Intent;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import androidx.browser.customtabs.CustomTabsIntent;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */
@CapacitorPlugin(name = "TrekShell")
public class TrekShellPlugin extends Plugin {
/** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600;
private String authCallId;
private String authScheme;
private boolean authBrowserOpen;
private final Handler handler = new Handler(Looper.getMainLooper());
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
JSObject result = new JSObject();
result.put("url", url == null ? JSONObject.NULL : url);
call.resolve(result);
}
@PluginMethod
public void setServer(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;
}
TrekServerStore.setUrl(getContext(), url);
call.resolve();
restart();
}
@PluginMethod
public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null);
call.resolve();
restart();
}
@PluginMethod
public void reload(PluginCall call) {
call.resolve();
restart();
}
/** The server URL is part of the bridge's configuration, so it takes a new activity. */
private void restart() {
getActivity().runOnUiThread(() -> getActivity().recreate());
}
@PluginMethod
public void authenticate(PluginCall call) {
String url = call.getString("url");
String scheme = call.getString("callbackScheme");
if (url == null || scheme == null) {
call.reject("url and callbackScheme are required");
return;
}
rejectPendingAuth("Superseded by a new sign-in");
call.setKeepAlive(true);
getBridge().saveCall(call);
authCallId = call.getCallbackId();
authScheme = scheme;
authBrowserOpen = true;
// A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml.
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url));
}
@Override
protected void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
Uri data = intent.getData();
PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return;
JSObject result = new JSObject();
result.put("url", data.toString());
call.resolve(result);
finishAuth(call);
}
/**
* Back in the app without a redirect means the user closed the browser.
* onNewIntent runs before onResume when the redirect did arrive, so the
* grace period only covers devices that deliver the two out of order.
*/
@Override
protected void handleOnResume() {
super.handleOnResume();
if (!authBrowserOpen) return;
authBrowserOpen = false;
handler.postDelayed(() -> {
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("Sign-in cancelled", "CANCELLED");
finishAuth(call);
}, CANCEL_GRACE_MS);
}
private PluginCall pendingAuthCall() {
return authCallId == null ? null : getBridge().getSavedCall(authCallId);
}
private void rejectPendingAuth(String message) {
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject(message, "CANCELLED");
finishAuth(call);
}
private void finishAuth(PluginCall call) {
getBridge().releaseCall(call);
authCallId = null;
authScheme = null;
authBrowserOpen = false;
}
/** iOS only: Android keeps the service worker, which already starts TREK offline. */
@PluginMethod
public void refreshOfflineCopy(PluginCall call) {
JSObject result = new JSObject();
result.put("saved", false);
call.resolve(result);
}
@PluginMethod
public void openOfflineCopy(PluginCall call) {
JSObject result = new JSObject();
result.put("opened", false);
call.resolve(result);
}
}
@@ -4,8 +4,4 @@
<string name="title_activity_main">TREK - Plan your trips!</string>
<string name="package_name">com.liketrek.trek</string>
<string name="custom_url_scheme">com.liketrek.trek</string>
<!-- Referenced by the FCM default_notification_channel_id meta-data in
AndroidManifest.xml. A channel with this exact id must also be created
at runtime via PushNotifications.createChannel(). -->
<string name="default_notification_channel_id">trek_default</string>
</resources>
-36
View File
@@ -5,45 +5,12 @@ project(':capacitor-android').projectDir = new File('../../node_modules/@capacit
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../../node_modules/@capacitor/browser/android')
include ':capacitor-calendar'
project(':capacitor-calendar').projectDir = new File('../../node_modules/@capacitor/calendar/android')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../../node_modules/@capacitor/camera/android')
include ':capacitor-file-transfer'
project(':capacitor-file-transfer').projectDir = new File('../../node_modules/@capacitor/file-transfer/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../../node_modules/@capacitor/geolocation/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacitor/haptics/android')
include ':capacitor-inappbrowser'
project(':capacitor-inappbrowser').projectDir = new File('../../node_modules/@capacitor/inappbrowser/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../../node_modules/@capacitor/local-notifications/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../../node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../../node_modules/@capacitor/share/android')
@@ -52,6 +19,3 @@ project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@c
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../../node_modules/@capacitor/status-bar/android')
include ':capacitor-toast'
project(':capacitor-toast').projectDir = new File('../../node_modules/@capacitor/toast/android')
@@ -1,44 +0,0 @@
{
"_comment": [
"Template only — this file is not read by the build.",
"",
"To enable Android push notifications:",
" 1. Create a Firebase project at https://console.firebase.google.com",
" 2. Add an Android app with package name com.liketrek.trek",
" 3. Download the generated google-services.json",
" 4. Place it at client/android/app/google-services.json (gitignored)",
"",
"Without it, app/build.gradle skips the google-services plugin and the app",
"still builds and runs — PushNotifications.register() simply fails at runtime.",
"",
"The server that SENDS the notifications needs a service-account key from the",
"SAME Firebase project. See docs for the self-hosting implications."
],
"project_info": {
"project_number": "000000000000",
"project_id": "your-firebase-project-id",
"storage_bucket": "your-firebase-project-id.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000000000",
"android_client_info": {
"package_name": "com.liketrek.trek"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "REPLACE_WITH_YOUR_ANDROID_API_KEY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+3 -9
View File
@@ -1,10 +1,5 @@
ext {
// 26, not Capacitor's default 24: @capacitor/inappbrowser requires a minimum
// target of 26. This drops Android 7.0/7.1 (~1% of devices and no longer
// receiving security updates). Removing @capacitor/inappbrowser — its system-
// browser mode overlaps @capacitor/browser almost exactly — would allow 24
// again.
minSdkVersion = 26
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
@@ -18,7 +13,6 @@ ext {
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
// Consumed by @capacitor/push-notifications. Pinned rather than left to the
// plugin's default so an FCM SDK bump is a visible, reviewable change here.
firebaseMessagingVersion = '25.0.1'
// Custom Tabs for SSO and passkey sign-in (TrekShellPlugin.authenticate).
androidxBrowserVersion = '1.8.0'
}