Skip to content

Commit 98c903a

Browse files
committed
🎨 Extension icons: parse manifest icons & display in UI
1 parent 94748b4 commit 98c903a

File tree

5 files changed

+120
-26
lines changed

5 files changed

+120
-26
lines changed

‎app/src/main/java/blinker/go/data/extension/ExtensionInfo.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ data class ExtensionInfo(
1616
val contentScripts: List<ContentScript> = emptyList(),
1717
val enabled: Boolean = true,
1818
val type: ExtensionType = ExtensionType.CRX,
19-
val permissions: List<String> = emptyList()
19+
val permissions: List<String> = emptyList(),
20+
val iconPath: String? = null
2021
)
2122

2223
enum class ExtensionType(val label: String) {

‎app/src/main/java/blinker/go/data/extension/ExtensionManager.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package blinker.go.data.extension
22

33
import android.content.Context
4+
import android.graphics.Bitmap
5+
import android.graphics.BitmapFactory
46
import android.net.Uri
57
import org.json.JSONArray
68
import org.json.JSONObject
@@ -63,6 +65,15 @@ class ExtensionManager(private val context: Context) {
6365
} else null
6466
}
6567

68+
fun loadIcon(extensionId: String, iconPath: String?): Bitmap? {
69+
if (iconPath == null) return null
70+
val file = File(context.filesDir, "extensions/$extensionId/$iconPath")
71+
if (!file.exists()) return null
72+
return try {
73+
BitmapFactory.decodeFile(file.absolutePath)
74+
} catch (_: Exception) { null }
75+
}
76+
6677
private fun urlMatches(
6778
url: String,
6879
matches: List<String>,
@@ -98,6 +109,7 @@ class ExtensionManager(private val context: Context) {
98109
put("description", ext.description)
99110
put("enabled", ext.enabled)
100111
put("type", ext.type.name)
112+
put("iconPath", ext.iconPath ?: "")
101113
put("permissions", JSONArray(ext.permissions))
102114
put("content_scripts", JSONArray().apply {
103115
ext.contentScripts.forEach { cs ->
@@ -114,6 +126,7 @@ class ExtensionManager(private val context: Context) {
114126

115127
private fun deserialize(obj: JSONObject): ExtensionInfo {
116128
val csArray = obj.optJSONArray("content_scripts") ?: JSONArray()
129+
val iconPathStr = obj.optString("iconPath", "")
117130
return ExtensionInfo(
118131
id = obj.getString("id"),
119132
name = obj.getString("name"),
@@ -124,6 +137,7 @@ class ExtensionManager(private val context: Context) {
124137
ExtensionType.valueOf(obj.optString("type", "CRX"))
125138
} catch (_: Exception) { ExtensionType.CRX },
126139
permissions = toList(obj.optJSONArray("permissions")),
140+
iconPath = iconPathStr.ifEmpty { null },
127141
contentScripts = (0 until csArray.length()).map { i ->
128142
val cs = csArray.getJSONObject(i)
129143
ContentScript(

‎app/src/main/java/blinker/go/data/extension/ExtensionParser.kt‎

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,14 @@ object ExtensionParser {
5959
return null
6060
}
6161

62-
// Load i18n messages
6362
val messages = loadMessages(manifest, extDir)
64-
65-
return buildInfo(manifest, id, type, messages)
63+
return buildInfo(manifest, id, type, messages, extDir)
6664
} catch (_: Exception) {
6765
extDir.deleteRecursively()
6866
return null
6967
}
7068
}
7169

72-
/**
73-
* Load localized messages from _locales directory.
74-
* Tries: default_locale → en → en_US → first available
75-
*/
7670
private fun loadMessages(
7771
manifest: JSONObject,
7872
extDir: File
@@ -81,8 +75,6 @@ object ExtensionParser {
8175
if (!localesDir.exists()) return emptyMap()
8276

8377
val defaultLocale = manifest.optString("default_locale", "")
84-
85-
// Try locales in priority order
8678
val candidates = mutableListOf<String>()
8779
if (defaultLocale.isNotEmpty()) candidates.add(defaultLocale)
8880
candidates.addAll(listOf("en", "en_US", "en_GB"))
@@ -96,7 +88,6 @@ object ExtensionParser {
9688
}
9789
}
9890

99-
// Fallback: first available locale
10091
if (messagesFile == null) {
10192
localesDir.listFiles()?.firstOrNull { it.isDirectory }?.let { dir ->
10293
val f = File(dir, "messages.json")
@@ -122,19 +113,64 @@ object ExtensionParser {
122113
}
123114
}
124115

125-
/**
126-
* Resolve __MSG_key__ placeholders using messages map
127-
*/
128116
private fun resolveI18n(text: String, messages: Map<String, String>): String {
129117
if (!text.contains("__MSG_")) return text
130-
131118
val regex = Regex("__MSG_(\\w+)__")
132119
return regex.replace(text) { match ->
133120
val key = match.groupValues[1].lowercase()
134121
messages[key] ?: match.value
135122
}
136123
}
137124

125+
/**
126+
* Extract best icon path from manifest.
127+
* Prefers largest available: 128 > 96 > 64 > 48 > 32 > 16
128+
*/
129+
private fun findBestIcon(manifest: JSONObject, extDir: File): String? {
130+
val iconsObj = manifest.optJSONObject("icons")
131+
if (iconsObj != null) {
132+
val preferred = listOf("128", "96", "64", "48", "32", "16")
133+
for (size in preferred) {
134+
val path = iconsObj.optString(size, "")
135+
if (path.isNotEmpty()) {
136+
val file = File(extDir, path)
137+
if (file.exists()) return path
138+
}
139+
}
140+
// Fallback: try any available key
141+
iconsObj.keys().forEach { key ->
142+
val path = iconsObj.optString(key, "")
143+
if (path.isNotEmpty()) {
144+
val file = File(extDir, path)
145+
if (file.exists()) return path
146+
}
147+
}
148+
}
149+
150+
// Try browser_action or action icons
151+
val action = manifest.optJSONObject("browser_action")
152+
?: manifest.optJSONObject("action")
153+
if (action != null) {
154+
val actionIcons = action.optJSONObject("default_icon")
155+
if (actionIcons != null) {
156+
actionIcons.keys().forEach { key ->
157+
val path = actionIcons.optString(key, "")
158+
if (path.isNotEmpty()) {
159+
val file = File(extDir, path)
160+
if (file.exists()) return path
161+
}
162+
}
163+
}
164+
val singleIcon = action.optString("default_icon", "")
165+
if (singleIcon.isNotEmpty()) {
166+
val file = File(extDir, singleIcon)
167+
if (file.exists()) return singleIcon
168+
}
169+
}
170+
171+
return null
172+
}
173+
138174
private fun detectType(bytes: ByteArray): ExtensionType? {
139175
if (bytes.size < 4) return null
140176
if (bytes[0] == 0x43.toByte() && bytes[1] == 0x72.toByte() &&
@@ -183,15 +219,16 @@ object ExtensionParser {
183219
manifest: JSONObject,
184220
id: String,
185221
type: ExtensionType,
186-
messages: Map<String, String>
222+
messages: Map<String, String>,
223+
extDir: File
187224
): ExtensionInfo {
188225
val rawName = manifest.optString("name", "Unknown Extension")
189226
val rawDesc = manifest.optString("description", "")
190227
val version = manifest.optString("version", "0.0")
191228

192-
// Resolve i18n placeholders
193229
val name = resolveI18n(rawName, messages)
194230
val description = resolveI18n(rawDesc, messages)
231+
val iconPath = findBestIcon(manifest, extDir)
195232

196233
val contentScripts = mutableListOf<ContentScript>()
197234
manifest.optJSONArray("content_scripts")?.let { csArray ->
@@ -216,7 +253,8 @@ object ExtensionParser {
216253
description = description,
217254
contentScripts = contentScripts,
218255
type = type,
219-
permissions = toList(manifest.optJSONArray("permissions"))
256+
permissions = toList(manifest.optJSONArray("permissions")),
257+
iconPath = iconPath
220258
)
221259
}
222260

‎app/src/main/java/blinker/go/ui/browser/BrowserScreen.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ fun BrowserScreen(
421421

422422
if (showExtensions) {
423423
ExtensionsSheet(
424+
loadIcon = { id, path -> extensionManager.loadIcon(id, path) },
424425
extensions = extensionList,
425426
onDismiss = { showExtensions = false },
426427
onToggle = { id, enabled ->

‎app/src/main/java/blinker/go/ui/extensions/ExtensionsSheet.kt‎

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package blinker.go.ui.extensions
22

3+
import android.graphics.Bitmap
4+
import androidx.compose.foundation.Image
5+
import androidx.compose.foundation.background
36
import androidx.compose.foundation.layout.Box
47
import androidx.compose.foundation.layout.Column
58
import androidx.compose.foundation.layout.Row
@@ -29,8 +32,12 @@ import androidx.compose.material3.Switch
2932
import androidx.compose.material3.Text
3033
import androidx.compose.material3.rememberModalBottomSheetState
3134
import androidx.compose.runtime.Composable
35+
import androidx.compose.runtime.remember
3236
import androidx.compose.ui.Alignment
3337
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.draw.clip
39+
import androidx.compose.ui.graphics.asImageBitmap
40+
import androidx.compose.ui.layout.ContentScale
3441
import androidx.compose.ui.text.style.TextOverflow
3542
import androidx.compose.ui.unit.dp
3643
import blinker.go.data.extension.ExtensionInfo
@@ -42,7 +49,8 @@ fun ExtensionsSheet(
4249
onDismiss: () -> Unit,
4350
onToggle: (String, Boolean) -> Unit,
4451
onDelete: (String) -> Unit,
45-
onInstall: () -> Unit
52+
onInstall: () -> Unit,
53+
loadIcon: (String, String?) -> Bitmap?
4654
) {
4755
ModalBottomSheet(
4856
onDismissRequest = onDismiss,
@@ -86,6 +94,9 @@ fun ExtensionsSheet(
8694
items(extensions, key = { it.id }) { ext ->
8795
ExtensionItem(
8896
ext = ext,
97+
icon = remember(ext.id, ext.iconPath) {
98+
loadIcon(ext.id, ext.iconPath)
99+
},
89100
onToggle = { enabled -> onToggle(ext.id, enabled) },
90101
onDelete = { onDelete(ext.id) }
91102
)
@@ -121,6 +132,7 @@ fun ExtensionsSheet(
121132
@Composable
122133
private fun ExtensionItem(
123134
ext: ExtensionInfo,
135+
icon: Bitmap?,
124136
onToggle: (Boolean) -> Unit,
125137
onDelete: () -> Unit
126138
) {
@@ -130,14 +142,41 @@ private fun ExtensionItem(
130142
.padding(horizontal = 20.dp, vertical = 12.dp),
131143
verticalAlignment = Alignment.CenterVertically
132144
) {
133-
Icon(
134-
imageVector = Icons.Rounded.Extension,
135-
contentDescription = null,
136-
modifier = Modifier.size(36.dp),
137-
tint = if (ext.enabled) MaterialTheme.colorScheme.primary
138-
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
139-
)
145+
// Extension icon
146+
Box(
147+
modifier = Modifier
148+
.size(40.dp)
149+
.clip(RoundedCornerShape(8.dp))
150+
.background(
151+
MaterialTheme.colorScheme.surfaceVariant.copy(
152+
alpha = if (ext.enabled) 1f else 0.5f
153+
)
154+
),
155+
contentAlignment = Alignment.Center
156+
) {
157+
if (icon != null && !icon.isRecycled) {
158+
Image(
159+
bitmap = icon.asImageBitmap(),
160+
contentDescription = ext.name,
161+
modifier = Modifier
162+
.size(32.dp)
163+
.clip(RoundedCornerShape(4.dp)),
164+
contentScale = ContentScale.Fit,
165+
alpha = if (ext.enabled) 1f else 0.4f
166+
)
167+
} else {
168+
Icon(
169+
imageVector = Icons.Rounded.Extension,
170+
contentDescription = null,
171+
modifier = Modifier.size(24.dp),
172+
tint = if (ext.enabled) MaterialTheme.colorScheme.primary
173+
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
174+
)
175+
}
176+
}
177+
140178
Spacer(Modifier.width(12.dp))
179+
141180
Column(modifier = Modifier.weight(1f)) {
142181
Text(
143182
text = ext.name,
@@ -168,6 +207,7 @@ private fun ExtensionItem(
168207
)
169208
}
170209
}
210+
171211
Switch(checked = ext.enabled, onCheckedChange = onToggle)
172212
Spacer(Modifier.width(4.dp))
173213
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {

0 commit comments

Comments
 (0)