mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user