feat: better handle back button and exit

This commit is contained in:
Sarto
2026-05-01 22:49:52 +03:30
parent 68009f5d92
commit ace8ce8627
3 changed files with 180 additions and 13 deletions
@@ -28,6 +28,27 @@ class AndroidBridge(private val activity: Activity) {
@JavascriptInterface
fun isAndroid(): Boolean = true
// ===== App lifecycle (back-button confirmation) =====
@JavascriptInterface
fun minimizeApp() {
Handler(Looper.getMainLooper()).post {
activity.moveTaskToBack(true)
}
}
@JavascriptInterface
fun killApp() {
Handler(Looper.getMainLooper()).post {
activity.stopService(Intent(activity, ThefeedService::class.java))
activity.finishAndRemoveTask()
// Belt-and-braces: ensure the JVM exits even if a stray
// foreground notification or pending Intent keeps the
// process alive after finishAndRemoveTask().
android.os.Process.killProcess(android.os.Process.myPid())
}
}
// ===== Language =====
@JavascriptInterface
@@ -155,19 +155,11 @@ class MainActivity : ComponentActivity() {
private fun registerBackHandler() {
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Check if the chat view is open (mobile nav). If yes, go back
// to the channel list. If already on the channel list, minimize.
// Uses openSidebar() directly instead of webView.goBack() to avoid
// history-stack mismatches that can leave the UI stuck mid-transition.
webView.evaluateJavascript(
"(document.getElementById('app').classList.contains('chat-open')).toString()"
) { result ->
if (result.trim('"') == "true") {
webView.evaluateJavascript("openSidebar(); history.back();", null)
} else {
moveTaskToBack(true)
}
}
// Delegate to JS — it knows about open lightboxes, modals,
// and chat-open state, and can show the close-confirmation
// dialog at app root. JS calls back through AndroidBridge
// (minimizeApp / killApp) when the user picks an option.
webView.evaluateJavascript("window.handleAndroidBack && window.handleAndroidBack();", null)
}
})
}
+154
View File
@@ -2606,6 +2606,10 @@
media_slow_only: 'رله گیتهاب در دسترس نیست. دانلود از مسیر DNS خیلی کند است. ادامه می‌دهی؟',
media_rate_limited: 'محدودیت گیتهاب پر شد ({n} دقیقه تا ریست). از مسیر DNS ادامه می‌دهیم.',
media_rate_limited_short: 'محدودیت تعداد درخواست گیتهاب',
close_confirm: 'بستن thefeed؟',
close_cancel: 'نبند',
close_background: 'بستن، اما در پس‌زمینه فعال بمونه',
close_kill: 'بستن و توقف کامل سرویس',
media_size_mismatch: 'سایز فایل با چیزی که سرور گفته بود نمی‌خونه',
clear_cache: 'پاک کردن کش', clear_cache_btn: '🗑 پاک کردن کش', cache_cleared: 'کش پاک شد!',
saved_resolvers_title: 'شروع سریع',
@@ -2775,6 +2779,10 @@
media_slow_only: 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?',
media_rate_limited: 'GitHub rate limit hit ({n} min until reset). Falling back to slow DNS path.',
media_rate_limited_short: 'GitHub rate limit',
close_confirm: 'Close thefeed?',
close_cancel: "Don't close",
close_background: 'Close UI but keep running in background',
close_kill: 'Close and stop the service',
media_size_mismatch: 'Downloaded size doesn\'t match the manifest',
clear_cache: 'Clear Cache', clear_cache_btn: '🗑 Clear Cache', cache_cleared: 'Cache cleared!',
saved_resolvers_title: 'Quick Start',
@@ -4818,6 +4826,55 @@
// way of getting around blob-URL / Web-Share limitations on Android.
var androidBridge = (typeof window !== 'undefined' && window.Android) ? window.Android : null;
// Single entry point invoked by MainActivity.kt's back-press handler.
// Returns nothing — the JS side resolves the action itself, calling
// back into the bridge for "minimize"/"kill" if the user picks one
// of those from the close-confirmation dialog.
window.handleAndroidBack = async function () {
if (closeMediaLightbox()) return;
var openModal = document.querySelector('.modal-overlay.active');
if (openModal) { openModal.classList.remove('active'); return; }
if (mobileQuery.matches && document.getElementById('app').classList.contains('chat-open')) {
openSidebar();
return;
}
// At app root — confirm before closing/killing the process.
var choice = await showCloseConfirm();
if (choice === 'background' && androidBridge && androidBridge.minimizeApp) {
androidBridge.minimizeApp();
} else if (choice === 'kill' && androidBridge && androidBridge.killApp) {
androidBridge.killApp();
}
// 'cancel' or unknown → stay in the app.
};
function showCloseConfirm() {
return new Promise(function (resolve) {
var existing = document.getElementById('closeConfirmModal');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'closeConfirmModal';
overlay.className = 'modal-overlay active';
overlay.innerHTML =
'<div class="modal" style="max-width:380px">'
+ '<p style="font-size:14px;color:var(--text);margin-bottom:18px;line-height:1.6">'
+ esc(t('close_confirm') || 'Close thefeed?') + '</p>'
+ '<div class="modal-actions" style="flex-direction:column;gap:8px">'
+ '<button class="btn btn-flat" id="closeCancelBtn">' + esc(t('close_cancel') || "Don't close") + '</button>'
+ '<button class="btn btn-flat" id="closeBgBtn">' + esc(t('close_background') || 'Close, keep running in background') + '</button>'
+ '<button class="btn btn-primary" id="closeKillBtn" style="background:var(--danger,#e74c3c)">' + esc(t('close_kill') || 'Close and stop service') + '</button>'
+ '</div></div>';
document.body.appendChild(overlay);
var done = function (choice) {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
resolve(choice);
};
document.getElementById('closeCancelBtn').onclick = function () { done('cancel'); };
document.getElementById('closeBgBtn').onclick = function () { done('background'); };
document.getElementById('closeKillBtn').onclick = function () { done('kill'); };
});
}
function blobToBase64(blob) {
return new Promise(function (resolve, reject) {
var fr = new FileReader();
@@ -4909,6 +4966,7 @@
var media = overlay.querySelector('audio,video');
if (media) { try { media.pause(); } catch (e) { } }
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
mediaLightboxCloser = null;
};
var onClick = function (e) {
if (e.target === overlay || (e.target.classList && e.target.classList.contains('media-lightbox-close'))) {
@@ -4919,6 +4977,7 @@
overlay.addEventListener('click', onClick);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
mediaLightboxCloser = close;
}
function mediaFilenameFor(msgID, tag, mime) {
@@ -4931,6 +4990,19 @@
return fname;
}
// Tracked close handler so the Android back button (and any other
// place outside the lightbox) can dismiss it cleanly.
var mediaLightboxCloser = null;
function closeMediaLightbox() {
if (mediaLightboxCloser) {
try { mediaLightboxCloser(); } catch (e) { }
mediaLightboxCloser = null;
return true;
}
return false;
}
function showImageLightbox(blobURL, alt) {
var existing = document.getElementById('mediaLightbox');
if (existing) existing.remove();
@@ -4943,8 +5015,12 @@
overlay.removeEventListener('click', onClick);
document.removeEventListener('keydown', onKey);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
mediaLightboxCloser = null;
};
var onClick = function (e) {
// Don't dismiss while pinching/dragging — the click event fires
// on touchend after a multi-touch zoom and would close the box.
if (zoomBusy) return;
if (e.target === overlay || (e.target.classList && e.target.classList.contains('media-lightbox-close'))) {
close();
}
@@ -4953,6 +5029,84 @@
overlay.addEventListener('click', onClick);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
mediaLightboxCloser = close;
// Pinch-zoom + pan + double-tap on the image.
var imgEl = overlay.querySelector('.media-lightbox-img');
var zoomBusy = false;
attachPinchZoom(imgEl, function (busy) { zoomBusy = busy; });
}
// Two-finger pinch + drag-to-pan + double-tap toggle. Uses
// CSS transform so we don't need a library. busyCb fires true on
// multi-touch start and false a tick after touchend so the click
// close handler can ignore the trailing tap.
function attachPinchZoom(el, busyCb) {
var scale = 1, startScale = 1;
var translateX = 0, translateY = 0;
var startTX = 0, startTY = 0;
var pinchStartDist = 0;
var lastTap = 0;
var lastTouchEnd = 0;
el.style.touchAction = 'none';
el.style.transformOrigin = 'center center';
el.style.transition = 'transform 0.1s ease-out';
function apply() {
el.style.transform = 'translate(' + translateX + 'px, ' + translateY + 'px) scale(' + scale + ')';
}
function distance(t0, t1) {
var dx = t0.clientX - t1.clientX;
var dy = t0.clientY - t1.clientY;
return Math.hypot(dx, dy);
}
el.addEventListener('touchstart', function (e) {
if (e.touches.length === 2) {
pinchStartDist = distance(e.touches[0], e.touches[1]);
startScale = scale;
if (busyCb) busyCb(true);
} else if (e.touches.length === 1 && scale > 1) {
startTX = e.touches[0].clientX - translateX;
startTY = e.touches[0].clientY - translateY;
if (busyCb) busyCb(true);
}
}, { passive: true });
el.addEventListener('touchmove', function (e) {
if (e.touches.length === 2 && pinchStartDist > 0) {
e.preventDefault();
var d = distance(e.touches[0], e.touches[1]);
scale = Math.max(1, Math.min(5, startScale * d / pinchStartDist));
if (scale === 1) { translateX = 0; translateY = 0; }
apply();
} else if (e.touches.length === 1 && scale > 1) {
e.preventDefault();
translateX = e.touches[0].clientX - startTX;
translateY = e.touches[0].clientY - startTY;
apply();
}
}, { passive: false });
el.addEventListener('touchend', function () {
lastTouchEnd = Date.now();
// Snap back: clear busy flag a tick later so the trailing
// synthetic click event can be filtered.
setTimeout(function () { if (busyCb) busyCb(false); }, 50);
pinchStartDist = 0;
});
el.addEventListener('click', function (e) {
// Ignore clicks that immediately follow a pinch.
if (Date.now() - lastTouchEnd < 100) { e.stopPropagation(); return; }
var now = Date.now();
if (now - lastTap < 300) {
e.stopPropagation();
if (scale === 1) {
scale = 2;
} else {
scale = 1; translateX = 0; translateY = 0;
}
apply();
}
lastTap = now;
});
}
async function mediaSave(msgID) {