feat: implement password protection and app customization features for Android

This commit is contained in:
Sarto
2026-04-15 17:24:57 +03:30
parent ebef4d0fd7
commit 0d236d3834
6 changed files with 447 additions and 20 deletions
@@ -0,0 +1,126 @@
package com.thefeed.android
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.webkit.JavascriptInterface
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
class AndroidBridge(private val activity: Activity) {
private val prefs by lazy {
activity.getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
}
// ===== Identity =====
@JavascriptInterface
fun isAndroid(): Boolean = true
/**
* Create a pinned shortcut on the home screen with a custom name and icon.
* @param name The label shown under the shortcut
* @param iconBase64 Base64-encoded image data (may include data:image/...;base64, prefix)
*/
@JavascriptInterface
fun createAppShortcut(name: String, iconBase64: String): Boolean {
return try {
val raw = if (iconBase64.contains(",")) {
iconBase64.substringAfter(",")
} else {
iconBase64
}
val bytes = Base64.decode(raw, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false
// Save icon to internal storage for persistence
val iconFile = File(activity.filesDir, "custom_shortcut_icon.png")
FileOutputStream(iconFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
prefs.edit()
.putString(PREF_CUSTOM_APP_NAME, name)
.putString(PREF_CUSTOM_ICON_PATH, iconFile.absolutePath)
.apply()
val icon = IconCompat.createWithBitmap(bitmap)
val shortcut = ShortcutInfoCompat.Builder(activity, "custom_launcher")
.setShortLabel(name)
.setLongLabel(name)
.setIcon(icon)
.setIntent(
Intent(activity, MainActivity::class.java).apply {
action = Intent.ACTION_MAIN
}
)
.build()
ShortcutManagerCompat.requestPinShortcut(activity, shortcut, null)
true
} catch (_: Exception) {
false
}
}
@JavascriptInterface
fun getCustomAppName(): String {
return prefs.getString(PREF_CUSTOM_APP_NAME, "") ?: ""
}
@JavascriptInterface
fun resetAppShortcut() {
prefs.edit()
.remove(PREF_CUSTOM_APP_NAME)
.remove(PREF_CUSTOM_ICON_PATH)
.apply()
val iconFile = File(activity.filesDir, "custom_shortcut_icon.png")
if (iconFile.exists()) iconFile.delete()
}
// ===== Password =====
@JavascriptInterface
fun hasPassword(): Boolean {
return prefs.getString(PREF_PASSWORD_HASH, null) != null
}
@JavascriptInterface
fun setPassword(password: String): Boolean {
if (password.isEmpty()) return false
prefs.edit().putString(PREF_PASSWORD_HASH, sha256(password)).apply()
return true
}
@JavascriptInterface
fun removePassword(currentPassword: String): Boolean {
val stored = prefs.getString(PREF_PASSWORD_HASH, null) ?: return false
if (sha256(currentPassword) != stored) return false
prefs.edit().remove(PREF_PASSWORD_HASH).apply()
return true
}
@JavascriptInterface
fun checkPassword(password: String): Boolean {
val stored = prefs.getString(PREF_PASSWORD_HASH, null) ?: return true // no password set
return sha256(password) == stored
}
private fun sha256(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(input.toByteArray(Charsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
companion object {
const val PREF_CUSTOM_APP_NAME = "custom_app_name"
const val PREF_CUSTOM_ICON_PATH = "custom_icon_path"
const val PREF_PASSWORD_HASH = "password_hash"
}
}
@@ -1,6 +1,7 @@
package com.thefeed.android
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -11,12 +12,17 @@ import android.os.Looper
import android.os.PowerManager
import android.net.Uri
import android.provider.Settings
import android.text.InputType
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.webkit.JsResult
import android.webkit.WebChromeClient
@@ -38,6 +44,7 @@ class MainActivity : ComponentActivity() {
private lateinit var txtStatus: TextView
private val handler = Handler(Looper.getMainLooper())
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
private var lockScreenVisible = false
private val fileChooserLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
@@ -78,7 +85,55 @@ class MainActivity : ComponentActivity() {
configureWebView()
registerBackHandler()
startThefeedService()
waitForServerThenLoad()
if (isPasswordSet()) {
showLockScreen()
} else {
waitForServerThenLoad()
}
}
private fun isPasswordSet(): Boolean {
val prefs = getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(AndroidBridge.PREF_PASSWORD_HASH, null) != null
}
@SuppressLint("SetTextI18n")
private fun showLockScreen() {
lockScreenVisible = true
val lockOverlay = findViewById<LinearLayout>(R.id.lockOverlay)
val lockInput = findViewById<EditText>(R.id.lockPasswordInput)
val lockBtn = findViewById<Button>(R.id.lockUnlockBtn)
val lockError = findViewById<TextView>(R.id.lockError)
lockOverlay.visibility = View.VISIBLE
webView.visibility = View.GONE
txtStatus.visibility = View.GONE
val bridge = AndroidBridge(this)
fun tryUnlock() {
val pw = lockInput.text.toString()
if (bridge.checkPassword(pw)) {
lockOverlay.visibility = View.GONE
webView.visibility = View.VISIBLE
lockScreenVisible = false
lockInput.text.clear()
lockError.visibility = View.GONE
waitForServerThenLoad()
} else {
lockError.text = "Wrong password"
lockError.visibility = View.VISIBLE
}
}
lockBtn.setOnClickListener { tryUnlock() }
lockInput.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
tryUnlock()
true
} else false
}
}
private fun registerBackHandler() {
@@ -86,11 +141,13 @@ class MainActivity : ComponentActivity() {
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.goBack()
webView.evaluateJavascript("openSidebar(); history.back();", null)
} else {
moveTaskToBack(true)
}
@@ -159,6 +216,7 @@ class MainActivity : ComponentActivity() {
txtStatus.visibility = if (msg.isEmpty()) View.GONE else View.VISIBLE
}
@SuppressLint("SetJavaScriptEnabled")
private fun configureWebView() {
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
@@ -228,6 +286,8 @@ class MainActivity : ComponentActivity() {
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
}
webView.addJavascriptInterface(AndroidBridge(this), "Android")
}
/**
@@ -1,23 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bg">
<TextView
android:id="@+id/txtStatus"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:textColor="@color/text"
android:visibility="gone"
android:text="@string/webview_loading" />
android:layout_height="match_parent"
android:orientation="vertical">
<WebView
android:id="@+id/webView"
<TextView
android:id="@+id/txtStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:textColor="@color/text"
android:visibility="gone"
android:text="@string/webview_loading" />
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
<!-- Password lock screen overlay -->
<LinearLayout
android:id="@+id/lockOverlay"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="48dp"
android:background="@color/bg"
android:visibility="gone">
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/text"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/enter_password"
android:textColor="@color/text"
android:textSize="14sp"
android:layout_marginBottom="24dp" />
<EditText
android:id="@+id/lockPasswordInput"
android:layout_width="match_parent"
android:layout_height="48dp"
android:hint="@string/password_hint"
android:inputType="textPassword"
android:imeOptions="actionDone"
android:background="@color/bgPanel"
android:textColor="@color/text"
android:textColorHint="#66EDF2FF"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/lockError"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#E74C3C"
android:textSize="13sp"
android:visibility="gone"
android:layout_marginBottom="12dp" />
<Button
android:id="@+id/lockUnlockBtn"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/unlock"
android:background="@color/accent"
android:textColor="@color/bg"
android:textAllCaps="false"
android:textSize="16sp" />
</LinearLayout>
</FrameLayout>
@@ -4,4 +4,7 @@
<string name="start_service">Start</string>
<string name="stop_service">Stop</string>
<string name="reload_webview">Reload</string>
<string name="enter_password">Enter password to unlock</string>
<string name="password_hint">Password</string>
<string name="unlock">Unlock</string>
</resources>
+175 -3
View File
@@ -1586,6 +1586,52 @@
<button class="btn btn-flat" onclick="clearBgImage()" style="font-size:11px;padding:4px 10px;color:var(--error)" data-i18n="clear_bg">Clear</button>
</div>
</div>
<!-- Android-only: App Customization -->
<div id="androidAppSection" style="display:none;margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
<label style="font-size:13px;font-weight:600;color:var(--text)" data-i18n="app_customization">App Customization</label>
<div style="margin-top:8px">
<label style="font-size:12px;color:var(--text-dim)" data-i18n="app_name_label">App Name</label>
<input type="text" id="customAppName" placeholder="thefeed"
style="width:100%;margin-top:4px;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box">
</div>
<div style="margin-top:8px">
<label style="font-size:12px;color:var(--text-dim)" data-i18n="app_icon_label">App Icon</label>
<div style="display:flex;gap:6px;align-items:center;margin-top:4px">
<img id="customIconPreview" src="" style="width:40px;height:40px;border-radius:8px;border:1px solid var(--border);display:none;object-fit:cover">
<input type="file" id="customIconInput" accept="image/*" style="flex:1;font-size:12px;color:var(--text)">
</div>
</div>
<div style="display:flex;gap:6px;margin-top:10px">
<button class="btn btn-primary" onclick="applyAppCustomization()" style="font-size:12px;padding:6px 14px" data-i18n="app_apply">Apply</button>
<button class="btn btn-flat" onclick="resetAppCustomization()" style="font-size:12px;padding:6px 14px;color:var(--error)" data-i18n="app_reset">Reset</button>
</div>
</div>
<!-- Android-only: App Password -->
<div id="androidPasswordSection" style="display:none;margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
<label style="font-size:13px;font-weight:600;color:var(--text)" data-i18n="app_password">App Password</label>
<div id="passwordSetSection" style="margin-top:8px">
<input type="password" id="appPasswordInput" placeholder=""
data-i18n-ph="password_new_ph"
style="width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box;margin-bottom:6px">
<input type="password" id="appPasswordConfirm" placeholder=""
data-i18n-ph="password_confirm_ph"
style="width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box">
<div id="passwordError" style="color:var(--error);font-size:12px;margin-top:4px;display:none"></div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn btn-primary" onclick="setAppPassword()" style="font-size:12px;padding:6px 14px" data-i18n="password_set">Set Password</button>
</div>
</div>
<div id="passwordRemoveSection" style="margin-top:8px;display:none">
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px" data-i18n="password_active">Password is active</div>
<input type="password" id="appPasswordCurrent" placeholder=""
data-i18n-ph="password_current_ph"
style="width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box">
<div id="passwordRemoveError" style="color:var(--error);font-size:12px;margin-top:4px;display:none"></div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn btn-flat" onclick="removeAppPassword()" style="font-size:12px;padding:6px 14px;color:var(--error)" data-i18n="password_remove">Remove Password</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-flat" onclick="closeSettings()" data-i18n="cancel">Cancel</button>
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="save">Save</button>
@@ -1900,7 +1946,7 @@
copied: 'کپی شد!', copy: 'کپی', active: 'فعال',
private: 'خصوصی', x_posts: 'پست‌های X', x_label: 'X', no_config: 'ابتدا پروفایل را ذخیره کنید',
refreshing: 'در حال بروزرسانی...', fetching_channel: 'در حال دریافت کانال...',
msg_copied: 'پیام کپی شد!', rescan_started: 'بررسی مجدد شروع شد',
msg_copied: 'پیام کپی شد!', rescan_started: 'بررسی مجدد شروع شد', no_new_messages: 'پیام جدیدی نیست',
add_manual: '✎ ساخت دستی', rescan: 'بررسی مجدد',
new_messages: 'پیام جدید', missed_messages: '{n} پیام از دست رفته یا حذف شده',
clear_cache: 'پاک کردن کش', cache_cleared: 'کش پاک شد!',
@@ -2032,7 +2078,7 @@
copied: 'URI copied!', copy: 'Copy', active: 'Active',
private: 'Private', x_posts: 'X Posts', x_label: 'X', no_config: 'Save a profile first',
refreshing: 'Refreshing...', fetching_channel: 'Fetching channel...',
msg_copied: 'Message copied!', rescan_started: 'Rescan started',
msg_copied: 'Message copied!', rescan_started: 'Rescan started', no_new_messages: 'No new messages',
add_manual: '✎ Create Manually', rescan: 'Rescan',
new_messages: 'New messages', missed_messages: '{n} messages missed or deleted',
clear_cache: 'Clear Cache', cache_cleared: 'Cache cleared!',
@@ -2127,6 +2173,26 @@
dns_timeout: 'DNS Query Timeout (s)',
auto_scan: 'Automatic hourly resolver check',
scanner_clear_targets: '\uD83D\uDDD1 Clear',
app_customization: 'App Customization',
app_name_label: 'App Name',
app_icon_label: 'App Icon',
app_apply: 'Apply',
app_reset: 'Reset',
app_shortcut_created: 'Shortcut created!',
app_shortcut_failed: 'Failed to create shortcut',
app_reset_done: 'Reset done!',
app_password: 'App Password',
password_new_ph: 'New password',
password_confirm_ph: 'Confirm password',
password_current_ph: 'Current password',
password_set: 'Set Password',
password_remove: 'Remove Password',
password_active: 'Password is active',
password_set_ok: 'Password set!',
password_removed: 'Password removed!',
password_mismatch: 'Passwords do not match',
password_wrong: 'Wrong password',
password_empty: 'Password cannot be empty',
}
};
var lang = localStorage.getItem('thefeed_lang') || 'fa';
@@ -2332,6 +2398,7 @@
function openSettings() {
renderLatestVersion();
applyThemeButtons();
initAndroidSettings();
document.getElementById('settingsModal').classList.add('active');
}
function closeSettings() { document.getElementById('settingsModal').classList.remove('active') }
@@ -2341,6 +2408,109 @@
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fontSize: fs, debug: dbg }) }) } catch (e) { }
closeSettings();
}
// ===== ANDROID SETTINGS =====
function initAndroidSettings() {
if (typeof Android === 'undefined') return;
// Show Android-only sections
document.getElementById('androidAppSection').style.display = '';
document.getElementById('androidPasswordSection').style.display = '';
// Populate current custom name
try {
var name = Android.getCustomAppName();
if (name) document.getElementById('customAppName').value = name;
} catch (e) { }
// Show correct password section
try {
var hasPw = Android.hasPassword();
document.getElementById('passwordSetSection').style.display = hasPw ? 'none' : '';
document.getElementById('passwordRemoveSection').style.display = hasPw ? '' : 'none';
} catch (e) { }
}
var _customIconData = null;
(function () {
// Listen for icon file selection
document.addEventListener('DOMContentLoaded', function () {
var inp = document.getElementById('customIconInput');
if (!inp) return;
inp.addEventListener('change', function () {
var file = this.files && this.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function (e) {
_customIconData = e.target.result;
var preview = document.getElementById('customIconPreview');
preview.src = _customIconData;
preview.style.display = '';
};
reader.readAsDataURL(file);
});
});
})();
function applyAppCustomization() {
if (typeof Android === 'undefined') return;
var name = document.getElementById('customAppName').value.trim() || 'thefeed';
if (!_customIconData) { showToast(t('app_icon_label')); return }
try {
var ok = Android.createAppShortcut(name, _customIconData);
showToast(ok ? t('app_shortcut_created') : t('app_shortcut_failed'));
} catch (e) { showToast(t('app_shortcut_failed')) }
}
function resetAppCustomization() {
if (typeof Android === 'undefined') return;
try {
Android.resetAppShortcut();
document.getElementById('customAppName').value = '';
document.getElementById('customIconPreview').style.display = 'none';
document.getElementById('customIconInput').value = '';
_customIconData = null;
showToast(t('app_reset_done'));
} catch (e) { }
}
function setAppPassword() {
if (typeof Android === 'undefined') return;
var pw = document.getElementById('appPasswordInput').value;
var confirm = document.getElementById('appPasswordConfirm').value;
var errEl = document.getElementById('passwordError');
errEl.style.display = 'none';
if (!pw) { errEl.textContent = t('password_empty'); errEl.style.display = ''; return }
if (pw !== confirm) { errEl.textContent = t('password_mismatch'); errEl.style.display = ''; return }
try {
var ok = Android.setPassword(pw);
if (ok) {
showToast(t('password_set_ok'));
document.getElementById('appPasswordInput').value = '';
document.getElementById('appPasswordConfirm').value = '';
document.getElementById('passwordSetSection').style.display = 'none';
document.getElementById('passwordRemoveSection').style.display = '';
}
} catch (e) { }
}
function removeAppPassword() {
if (typeof Android === 'undefined') return;
var pw = document.getElementById('appPasswordCurrent').value;
var errEl = document.getElementById('passwordRemoveError');
errEl.style.display = 'none';
if (!pw) { errEl.textContent = t('password_empty'); errEl.style.display = ''; return }
try {
var ok = Android.removePassword(pw);
if (ok) {
showToast(t('password_removed'));
document.getElementById('appPasswordCurrent').value = '';
document.getElementById('passwordSetSection').style.display = '';
document.getElementById('passwordRemoveSection').style.display = 'none';
} else {
errEl.textContent = t('password_wrong');
errEl.style.display = '';
}
} catch (e) { }
}
async function checkLatestVersion() {
var btn = document.getElementById('checkVersionBtn');
var prevText = btn ? btn.textContent : '';
@@ -2425,7 +2595,9 @@
closeProfiles();
await selectChannel(1); return
}
if (data && typeof data === 'object' && data.channel) {
if (data && typeof data === 'object' && data.type === 'no_changes') {
showToast(t('no_new_messages'));
} else if (data && typeof data === 'object' && data.channel) {
if (data.channel === snapChannel) await loadMessages(data.channel)
} else if (snapChannel > 0) { await loadMessages(snapChannel) }
updateSendPanel();
+1 -1
View File
@@ -1121,7 +1121,7 @@ func (s *Server) refreshChannel(channelNum int) {
s.mu.RUnlock()
if prevID > 0 && ch.LastMsgID == prevID && ch.ContentHash == prevHash && len(prevMsgs) > 0 {
s.addLog(fmt.Sprintf("Channel %s: no changes (last ID: %d)", ch.Name, prevID))
s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"no_changes\",\"channel\":%d}\n\n", channelNum))
return
}