diff --git a/android/android/app/capacitor.build.gradle b/android/android/app/capacitor.build.gradle
index 1c7f79a..bcbdf3c 100644
--- a/android/android/app/capacitor.build.gradle
+++ b/android/android/app/capacitor.build.gradle
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
+ implementation project(':capacitor-app')
implementation project(':capacitor-push-notifications')
}
diff --git a/android/android/capacitor.settings.gradle b/android/android/capacitor.settings.gradle
index 3c10fd1..5cfd102 100644
--- a/android/android/capacitor.settings.gradle
+++ b/android/android/capacitor.settings.gradle
@@ -2,5 +2,8 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+include ':capacitor-app'
+project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
+
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
diff --git a/android/package-lock.json b/android/package-lock.json
index 64e1294..f5a2989 100644
--- a/android/package-lock.json
+++ b/android/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@capacitor/android": "6.2.1",
+ "@capacitor/app": "^6.0.2",
"@capacitor/core": "6.2.1",
"@capacitor/push-notifications": "6.0.5"
},
@@ -25,6 +26,15 @@
"@capacitor/core": "^6.2.0"
}
},
+ "node_modules/@capacitor/app": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-6.0.2.tgz",
+ "integrity": "sha512-SiGTGgslK4TbWJVImCUL1odul7/YFkVfkYtAYS9AAEzQpxBECBeRnuN3FFBcfZ9eiN1XxFBFchhiwpxtx/c7yQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": "^6.0.0"
+ }
+ },
"node_modules/@capacitor/cli": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-6.2.1.tgz",
diff --git a/android/package.json b/android/package.json
index 69a89ca..fa93929 100644
--- a/android/package.json
+++ b/android/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@capacitor/android": "6.2.1",
+ "@capacitor/app": "^6.0.2",
"@capacitor/core": "6.2.1",
"@capacitor/push-notifications": "6.0.5"
},
diff --git a/app/static/js/android-back-button.js b/app/static/js/android-back-button.js
new file mode 100644
index 0000000..50875cf
--- /dev/null
+++ b/app/static/js/android-back-button.js
@@ -0,0 +1,35 @@
+/* Keep Android back navigation inside MetalCircle until the calendar home. */
+(() => {
+ const capacitor = window.Capacitor;
+ if (!capacitor || capacitor.getPlatform() !== 'android' || typeof capacitor.addListener !== 'function') return;
+
+ try {
+ capacitor.addListener('App', 'backButton', ({canGoBack}) => {
+ const openDialog = document.querySelector('dialog[open]');
+ if (openDialog) {
+ openDialog.close();
+ return;
+ }
+
+ const openDetails = document.querySelector('details[open]');
+ if (openDetails) {
+ openDetails.open = false;
+ return;
+ }
+
+ if (location.pathname === '/') {
+ capacitor.nativePromise('App', 'exitApp', {}).catch(() => {});
+ return;
+ }
+
+ if (canGoBack) {
+ history.back();
+ return;
+ }
+
+ location.replace('/');
+ });
+ } catch (_) {
+ // The website remains usable when the native App plugin is unavailable.
+ }
+})();
diff --git a/app/templates/_language_switch.html b/app/templates/_language_switch.html
index 3e2995e..c637762 100644
--- a/app/templates/_language_switch.html
+++ b/app/templates/_language_switch.html
@@ -12,3 +12,4 @@
+
diff --git a/app/tests/test_android_back_button.js b/app/tests/test_android_back_button.js
new file mode 100644
index 0000000..368d88e
--- /dev/null
+++ b/app/tests/test_android_back_button.js
@@ -0,0 +1,73 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const test = require('node:test');
+const vm = require('node:vm');
+
+const script = fs.readFileSync(
+ new URL('../static/js/android-back-button.js', `file://${__filename}`),
+ 'utf8'
+);
+
+function createHarness({platform = 'android', pathname = '/', openDialog = false, openDetails = false} = {}) {
+ const calls = [];
+ const dialog = {close: () => calls.push('dialog.close')};
+ const details = {open: true};
+ const capacitor = {
+ getPlatform: () => platform,
+ addListener: (plugin, event, handler) => {
+ calls.push(`listener:${plugin}:${event}`);
+ capacitor.backHandler = handler;
+ return {remove: async () => {}};
+ },
+ nativePromise: (plugin, method) => {
+ calls.push(`${plugin}.${method}`);
+ return Promise.resolve();
+ }
+ };
+
+ vm.runInNewContext(script, {
+ window: {Capacitor: capacitor},
+ document: {
+ querySelector: selector => selector === 'dialog[open]'
+ ? (openDialog ? dialog : null)
+ : (openDetails ? details : null)
+ },
+ location: {pathname, replace: path => calls.push(`replace:${path}`)},
+ history: {back: () => calls.push('history.back')}
+ });
+ return {calls, capacitor, details};
+}
+
+test('Android back closes an open dialog or details panel first', () => {
+ const dialog = createHarness({openDialog: true});
+ dialog.capacitor.backHandler({canGoBack: true});
+ assert.deepEqual(dialog.calls, ['listener:App:backButton', 'dialog.close']);
+
+ const details = createHarness({openDetails: true});
+ details.capacitor.backHandler({canGoBack: true});
+ assert.equal(details.details.open, false);
+ assert.deepEqual(details.calls, ['listener:App:backButton']);
+});
+
+test('Android back follows WebView history away from the start page', () => {
+ const {calls, capacitor} = createHarness({pathname: '/messages'});
+ capacitor.backHandler({canGoBack: true});
+ assert.deepEqual(calls, ['listener:App:backButton', 'history.back']);
+});
+
+test('Android back returns to the start page when a deep link has no history', () => {
+ const {calls, capacitor} = createHarness({pathname: '/messages/42'});
+ capacitor.backHandler({canGoBack: false});
+ assert.deepEqual(calls, ['listener:App:backButton', 'replace:/']);
+});
+
+test('Android back exits only from the start page', () => {
+ const {calls, capacitor} = createHarness();
+ capacitor.backHandler({canGoBack: false});
+ assert.deepEqual(calls, ['listener:App:backButton', 'App.exitApp']);
+});
+
+test('web browsers do not register the native back handler', () => {
+ const {calls} = createHarness({platform: 'web'});
+ assert.deepEqual(calls, []);
+});