feat: add app customization features including dynamic icon and name changes

This commit is contained in:
Sarto
2026-04-15 18:32:04 +03:30
parent 0d236d3834
commit 99f63f2e8e
5 changed files with 133 additions and 33 deletions
+13 -2
View File
@@ -27,12 +27,23 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
android:launchMode="singleTask" />
<!-- Separate alias for launcher entry so it can be disabled
when the user applies a custom icon/name shortcut -->
<activity-alias
android:name=".DefaultLauncher"
android:targetActivity=".MainActivity"
android:exported="true"
android:enabled="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</activity-alias>
</application>
</manifest>
@@ -1,8 +1,10 @@
package com.thefeed.android
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
@@ -26,31 +28,30 @@ class AndroidBridge(private val activity: Activity) {
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)
* Change the app's launcher icon and name.
* If iconBase64 is non-empty, disables the default launcher alias and
* pins a new shortcut with the custom image — the result looks like
* the app itself changed.
* If iconBase64 is empty, only saves the custom name (used on lock screen).
*/
@JavascriptInterface
fun createAppShortcut(name: String, iconBase64: String): Boolean {
fun setAppIdentity(name: String, iconBase64: String): Boolean {
return try {
val raw = if (iconBase64.contains(",")) {
iconBase64.substringAfter(",")
} else {
iconBase64
}
prefs.edit().putString(PREF_CUSTOM_APP_NAME, name).apply()
if (iconBase64.isBlank()) return true // name-only change
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")
val iconFile = File(activity.filesDir, "custom_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()
prefs.edit().putString(PREF_CUSTOM_ICON_PATH, iconFile.absolutePath).apply()
// Create pinned shortcut with custom icon/name
val icon = IconCompat.createWithBitmap(bitmap)
val shortcut = ShortcutInfoCompat.Builder(activity, "custom_launcher")
.setShortLabel(name)
@@ -63,25 +64,67 @@ class AndroidBridge(private val activity: Activity) {
)
.build()
ShortcutManagerCompat.requestPinShortcut(activity, shortcut, null)
// Hide original launcher icon
activity.packageManager.setComponentEnabledSetting(
ComponentName(activity, "${activity.packageName}.DefaultLauncher"),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
true
} catch (_: Exception) {
false
}
}
/** Returns the custom display name, or empty string if using defaults. */
@JavascriptInterface
fun getCustomAppName(): String {
return prefs.getString(PREF_CUSTOM_APP_NAME, "") ?: ""
}
/** Returns the display name for the app — custom name if set, otherwise "thefeed". */
@JavascriptInterface
fun resetAppShortcut() {
fun getAppDisplayName(): String {
val custom = prefs.getString(PREF_CUSTOM_APP_NAME, null)
return if (!custom.isNullOrBlank()) custom else "thefeed"
}
/** Restore original app icon and name. */
@JavascriptInterface
fun resetAppIdentity() {
prefs.edit()
.remove(PREF_CUSTOM_APP_NAME)
.remove(PREF_CUSTOM_ICON_PATH)
.apply()
val iconFile = File(activity.filesDir, "custom_shortcut_icon.png")
val iconFile = File(activity.filesDir, "custom_icon.png")
if (iconFile.exists()) iconFile.delete()
// Restore original launcher icon
try {
activity.packageManager.setComponentEnabledSetting(
ComponentName(activity, "${activity.packageName}.DefaultLauncher"),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)
} catch (_: Exception) { }
// Remove dynamic shortcut
try {
ShortcutManagerCompat.removeDynamicShortcuts(activity, listOf("custom_launcher"))
} catch (_: Exception) { }
}
// ===== Language =====
@JavascriptInterface
fun setLang(lang: String) {
prefs.edit().putString(PREF_LANG, lang).apply()
}
@JavascriptInterface
fun getLang(): String {
return prefs.getString(PREF_LANG, "fa") ?: "fa"
}
// ===== Password =====
@@ -108,7 +151,7 @@ class AndroidBridge(private val activity: Activity) {
@JavascriptInterface
fun checkPassword(password: String): Boolean {
val stored = prefs.getString(PREF_PASSWORD_HASH, null) ?: return true // no password set
val stored = prefs.getString(PREF_PASSWORD_HASH, null) ?: return true
return sha256(password) == stored
}
@@ -122,5 +165,6 @@ class AndroidBridge(private val activity: Activity) {
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"
const val PREF_LANG = "app_lang"
}
}
@@ -102,15 +102,32 @@ class MainActivity : ComponentActivity() {
private fun showLockScreen() {
lockScreenVisible = true
val lockOverlay = findViewById<LinearLayout>(R.id.lockOverlay)
val lockTitle = findViewById<TextView>(R.id.lockTitle)
val lockSubtitle = findViewById<TextView>(R.id.lockSubtitle)
val lockInput = findViewById<EditText>(R.id.lockPasswordInput)
val lockBtn = findViewById<Button>(R.id.lockUnlockBtn)
val lockError = findViewById<TextView>(R.id.lockError)
val prefs = getSharedPreferences(ThefeedService.PREFS_NAME, Context.MODE_PRIVATE)
val appName = prefs.getString(AndroidBridge.PREF_CUSTOM_APP_NAME, null)
?.takeIf { it.isNotBlank() } ?: getString(R.string.app_name)
val lang = prefs.getString(AndroidBridge.PREF_LANG, "fa") ?: "fa"
val isPersian = lang == "fa"
lockTitle.text = appName
lockSubtitle.text = if (isPersian) "رمز عبور را وارد کنید" else "Enter password to unlock"
lockInput.hint = if (isPersian) "رمز عبور" else "Password"
lockBtn.text = if (isPersian) "ورود" else "Unlock"
if (isPersian) {
lockOverlay.layoutDirection = View.LAYOUT_DIRECTION_RTL
}
lockOverlay.visibility = View.VISIBLE
webView.visibility = View.GONE
txtStatus.visibility = View.GONE
val bridge = AndroidBridge(this)
val wrongPwText = if (isPersian) "رمز عبور اشتباه است" else "Wrong password"
fun tryUnlock() {
val pw = lockInput.text.toString()
@@ -122,7 +139,7 @@ class MainActivity : ComponentActivity() {
lockError.visibility = View.GONE
waitForServerThenLoad()
} else {
lockError.text = "Wrong password"
lockError.text = wrongPwText
lockError.visibility = View.VISIBLE
}
}
@@ -37,18 +37,18 @@
android:visibility="gone">
<TextView
android:id="@+id/lockTitle"
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:id="@+id/lockSubtitle"
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" />
@@ -57,7 +57,6 @@
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"
@@ -80,7 +79,6 @@
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"