From 2a803036b5298b39606be3633c2a0cb4130e33a4 Mon Sep 17 00:00:00 2001 From: MetalCircle Codex Bot Date: Tue, 15 Sep 2026 14:03:43 +0200 Subject: [PATCH] Handle Android back navigation in app --- android/android/app/capacitor.build.gradle | 1 + android/android/capacitor.settings.gradle | 3 + android/package-lock.json | 10 +++ android/package.json | 1 + app/static/js/android-back-button.js | 35 +++++++++++ app/templates/_language_switch.html | 1 + app/tests/test_android_back_button.js | 73 ++++++++++++++++++++++ 7 files changed, 124 insertions(+) create mode 100644 app/static/js/android-back-button.js create mode 100644 app/tests/test_android_back_button.js 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, []); +});