diff --git a/.github/workflows/generate-keystore.yml b/.github/workflows/generate-keystore.yml
new file mode 100644
index 0000000..31d97b1
--- /dev/null
+++ b/.github/workflows/generate-keystore.yml
@@ -0,0 +1,38 @@
+name: Generate Release Keystore
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Generate Release Keystore
+ run: |
+ keytool -genkeypair -alias consoleflow -keyalg RSA -keysize 2048 -validity 10000 -keystore release.keystore -storepass consoleflow123 -keypass consoleflow123 -dname "CN=consoleflow, OU=App, O=consoleflow, L=Unknown, ST=Unknown, C=US"
+
+ echo ""
+ echo "================================================"
+ echo " COPY THESE VALUES TO GITHUB SECRETS"
+ echo "================================================"
+ echo ""
+ echo "Secret name: KEYSTORE_B64"
+ echo "Secret value (copy everything between the lines):"
+ echo "----"
+ base64 -w 0 release.keystore
+ echo ""
+ echo "----"
+ echo ""
+ echo "Secret name: KEY_ALIAS"
+ echo "Secret value: consoleflow"
+ echo ""
+ echo "Secret name: KEYSTORE_PASSWORD"
+ echo "Secret value: consoleflow123"
+ echo ""
+ echo "Secret name: KEY_PASSWORD"
+ echo "Secret value: consoleflow123"
+ echo ""
diff --git a/.github/workflows/quick-apk.yml b/.github/workflows/quick-apk.yml
new file mode 100644
index 0000000..e093539
--- /dev/null
+++ b/.github/workflows/quick-apk.yml
@@ -0,0 +1,47 @@
+name: Quick APK
+
+on:
+ workflow_dispatch:
+ push:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build quick APK
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: gradle
+
+ - name: Make Gradle wrapper executable
+ run: chmod +x ./gradlew
+
+ - name: Build debug APK
+ run: ./gradlew assembleDebug --no-daemon
+
+ - name: Rename APK
+ id: rename
+ run: |
+ APK_PATH=$(find app/build/outputs/apk/debug -name '*.apk' | head -n 1)
+ SHORT_SHA="${GITHUB_SHA::7}"
+ NEW_NAME="ConsoleFlow-quick-${SHORT_SHA}.apk"
+ cp "$APK_PATH" "$NEW_NAME"
+ echo "apk_path=$NEW_NAME" >> "$GITHUB_OUTPUT"
+
+ - name: Upload APK artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ConsoleFlow-quick-apk
+ path: ${{ steps.rename.outputs.apk_path }}
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e8e649c..814a1ba 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,7 +8,7 @@ permissions:
jobs:
build:
- name: Build APK and Replace Release
+ name: Build Release APK and Replace Release
runs-on: ubuntu-latest
steps:
@@ -64,15 +64,26 @@ jobs:
gh release delete "$TAG" -y
fi
- - name: Build Debug APK
- run: ./gradlew assembleDebug --no-daemon
+ - name: Restore keystore from secret
+ env:
+ KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
+ run: |
+ echo "$KEYSTORE_B64" | base64 --decode > release.keystore
+
+ - name: Build Release APK
+ env:
+ KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
+ KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ run: ./gradlew assembleRelease --no-daemon
- name: Locate and Rename APK
id: apk
run: |
- ORIGINAL_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -1)
+ ORIGINAL_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
NEW_NAME="${{ steps.metadata.outputs.apk_custom_name }}"
- NEW_PATH="app/build/outputs/apk/debug/$NEW_NAME"
+ NEW_PATH="app/build/outputs/apk/release/$NEW_NAME"
mv "$ORIGINAL_PATH" "$NEW_PATH"
echo "path=$NEW_PATH" >> $GITHUB_OUTPUT
@@ -85,4 +96,4 @@ jobs:
prerelease: ${{ steps.metadata.outputs.pre }}
body: ${{ steps.metadata.outputs.body }}
files: ${{ steps.apk.outputs.path }}
- generate_release_notes: true
\ No newline at end of file
+ generate_release_notes: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d13a00b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.keystore
+*.jks
+release.keystore
+debug.keystore
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e69de29..f753091 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+## v2.2.8
+
+### What is new
+
+- Added a **new dedicated download management system** with its own page.
+- Added **full support** for controllers, keyboard, mouse, and TV remotes.
+- Fixed some issues and made other improvements.
+- New app logo
\ No newline at end of file
diff --git a/README.md b/README.md
index db89a58..4d08a3f 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,9 @@
**A lightweight, developer-focused Android web browser that brings desktop-like debugging to mobile.**
[](#)
-[](https://github.com/SANDRO00O/ConsoleFlow-mobile/stargazers)
-[](https://github.com/SANDRO00O/ConsoleFlow-mobile/network/members)
[](https://github.com/SANDRO00O/ConsoleFlow-mobile/blob/main/LICENSE)
+[](https://github.com/SANDRO00O/ConsoleFlow-mobile/releases)
+[](https://f-droid.org/en/packages/space.karrarnazim.ConsoleFlow/)
@@ -50,6 +50,7 @@
- **Bookmarks & History:** Built-in local storage manager for your favorite sites and browsing history.
- **Native Downloads:** Integrated with Android's native `DownloadManager` for stable file downloading.
- **Sleek UI:** Custom dark-themed start page, error pages, menus, and Android 12+ Splash Screen support.
+- **Full TV Support:** Optimized for Android TV and TV remotes, with complete controller, keyboard, mouse, and remote navigation support.
## Tech Stack & Libraries
diff --git a/app/build.gradle b/app/build.gradle
index 1ba7658..08eb7ed 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,12 +11,22 @@ defaultConfig {
applicationId "space.karrarnazim.ConsoleFlow"
minSdk 24
targetSdk 35
-versionCode 8
-versionName "2.1.8"
+versionCode 13
+versionName "2.2.8"
+}
+
+signingConfigs {
+release {
+storeFile file(System.getenv("KEYSTORE_FILE") ?: "$rootDir/release.keystore")
+storePassword System.getenv("KEYSTORE_PASSWORD")
+keyAlias System.getenv("KEY_ALIAS")
+keyPassword System.getenv("KEY_PASSWORD")
+}
}
buildTypes {
release {
+signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
@@ -44,4 +54,4 @@ implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4da3959..f3d50a5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,11 +9,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -41,9 +62,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/banner.svg b/app/src/main/assets/banner.svg
new file mode 100644
index 0000000..f647067
--- /dev/null
+++ b/app/src/main/assets/banner.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/AboutActivity.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/AboutActivity.kt
new file mode 100644
index 0000000..7e3c524
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/AboutActivity.kt
@@ -0,0 +1,149 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.webkit.WebView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+
+class AboutActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_about)
+
+ // ── back button
+ findViewById(R.id.btnBack).setOnClickListener { finish() }
+
+ // ── banner
+ loadBanner()
+
+ // ── current version
+ findViewById(R.id.tvVersion).text = "v${UpdateManager.currentVersion(this)}"
+
+ // ── links
+ mapOf(
+ R.id.linkWebsite to "https://consoleflow.karrarnazim.space",
+ R.id.linkDeveloper to "https://karrarnazim.space",
+ R.id.linkPrivacy to "https://consoleflow.karrarnazim.space/privacy",
+ R.id.linkGithub to "https://github.com/SANDRO00O/ConsoleFlow-mobile"
+ ).forEach { (viewId, url) ->
+ findViewById(viewId).setOnClickListener { openUrl(url) }
+ }
+
+ // ── update check (uses cache if fresh, hits network if stale)
+ startUpdateCheck(forceRefresh = false)
+
+ // ── manual check button
+ findViewById(R.id.btnCheckUpdate).setOnClickListener {
+ startUpdateCheck(forceRefresh = true)
+ }
+ }
+
+ // ─── banner ──────────────────────────────────────────────────────────────
+
+ private fun loadBanner() {
+ val wv = findViewById(R.id.bannerWebView)
+
+ // Calculate exact height from banner aspect ratio (viewBox 1778.04 × 250)
+ val horizontalPaddingPx = (32 * resources.displayMetrics.density).toInt()
+ val availableWidth = resources.displayMetrics.widthPixels - horizontalPaddingPx
+ val bannerHeight = (availableWidth / 7.112f).toInt()
+
+ wv.layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, bannerHeight)
+
+ wv.settings.loadWithOverviewMode = true
+ wv.settings.useWideViewPort = true
+ wv.isVerticalScrollBarEnabled = false
+ wv.isHorizontalScrollBarEnabled = false
+ wv.isScrollContainer = false
+ wv.setBackgroundColor(android.graphics.Color.TRANSPARENT)
+
+ val svg = assets.open("banner.svg").bufferedReader().readText()
+ val html = """
+
+
+
+$svg"""
+
+ wv.loadDataWithBaseURL(null, html, "text/html", "utf-8", null)
+ }
+
+ // ─── update logic ────────────────────────────────────────────────────────
+
+ private fun startUpdateCheck(forceRefresh: Boolean) {
+ showState(R.id.updateLoading)
+ UpdateManager.check(this, forceRefresh) { release, isUpdateAvailable ->
+ applyUpdateResult(release, isUpdateAvailable)
+ }
+ }
+
+ private fun applyUpdateResult(release: UpdateManager.ReleaseInfo?, isUpdateAvailable: Boolean) {
+ when {
+ release == null -> showState(R.id.updateError)
+ isUpdateAvailable -> {
+ showState(R.id.updateAvailable)
+ findViewById(R.id.tvLatestVersion).text = "v${release.latestVersion}"
+ findViewById(R.id.btnDownload).setOnClickListener {
+ openUrl(release.releaseUrl)
+ }
+ }
+ else -> showState(R.id.updateUpToDate)
+ }
+
+ if (release?.changelog?.isNotBlank() == true) {
+ val section = findViewById(R.id.changelogSection)
+ section.visibility = View.VISIBLE
+ findViewById(R.id.tvChangelog).text = formatChangelog(release.changelog)
+ }
+ }
+
+ /** Shows exactly one of the four update-state views, hides the rest. */
+ private fun showState(targetId: Int) {
+ listOf(R.id.updateLoading, R.id.updateUpToDate, R.id.updateAvailable, R.id.updateError)
+ .forEach { id ->
+ findViewById(id).visibility =
+ if (id == targetId) View.VISIBLE else View.GONE
+ }
+ }
+
+ // ─── changelog formatter ─────────────────────────────────────────────────
+
+ /**
+ * Converts the raw GitHub Markdown release body to clean plain text
+ * suitable for a standard Android TextView.
+ */
+ private fun formatChangelog(raw: String): String =
+ raw.lines()
+ .joinToString("\n") { line ->
+ when {
+ line.startsWith("## ") -> "\n${line.drop(3).uppercase()}"
+ line.startsWith("# ") -> "\n${line.drop(2).uppercase()}"
+ line.startsWith("- ") -> " • ${line.drop(2)}"
+ line.startsWith("* ") -> " • ${line.drop(2)}"
+ else -> line
+ }
+ }
+ .replace(Regex("""\*\*(.+?)\*\*""")) { it.groupValues[1] }
+ .replace(Regex("""`(.+?)`""")) { it.groupValues[1] }
+ .trimStart('\n')
+ .trim()
+
+ // ─── helpers ─────────────────────────────────────────────────────────────
+
+ private fun openUrl(url: String) {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }
+
+ override fun finish() {
+ super.finish()
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/BrowserConstants.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/BrowserConstants.kt
new file mode 100644
index 0000000..ea70ef9
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/BrowserConstants.kt
@@ -0,0 +1,5 @@
+package space.karrarnazim.ConsoleFlow
+
+internal const val PREFS_NAME = "ConsoleFlowPrefs"
+internal const val HOME_URL_CONST = "about:blank"
+internal const val MAX_LIVE_WEBVIEWS = 6
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/BrowserGraphics.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/BrowserGraphics.kt
new file mode 100644
index 0000000..92d6b2e
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/BrowserGraphics.kt
@@ -0,0 +1,133 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+
+internal fun isHomeUrl(url: String?): Boolean {
+ if (url.isNullOrEmpty()) return true
+ return url == HOME_URL_CONST || url == "about:blank" ||
+ url == "error://page" || url.startsWith("error://")
+}
+internal fun generateHomePreviewBitmap(width: Int = 540, height: Int = 900): Bitmap {
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+
+ canvas.drawColor(Color.BLACK)
+
+ val cardPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#2A2D34") }
+ val accentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#3A3A3A") }
+ val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.parseColor("#E5E5E5")
+ textSize = 40f
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }
+ val mutedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.parseColor("#7A7A7A")
+ textSize = 22f
+ }
+
+ fun roundRect(l: Float, t: Float, r: Float, b: Float, radius: Float, paint: Paint) {
+ canvas.drawRoundRect(RectF(l, t, r, b), radius, radius, paint)
+ }
+
+ val settingsStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ style = Paint.Style.STROKE
+ strokeWidth = 8f
+ strokeCap = Paint.Cap.ROUND
+ }
+ canvas.drawRoundRect(RectF(width - 92f, 22f, width - 22f, 92f), 18f, 18f, accentPaint)
+ canvas.drawLine(width - 72f, 42f, width - 42f, 42f, settingsStroke)
+ canvas.drawLine(width - 72f, 62f, width - 52f, 62f, settingsStroke)
+ canvas.drawLine(width - 72f, 82f, width - 62f, 82f, settingsStroke)
+ canvas.drawLine(width - 50f, 28f, width - 50f, 34f, settingsStroke)
+ canvas.drawLine(width - 30f, 48f, width - 30f, 54f, settingsStroke)
+ canvas.drawLine(width - 50f, 68f, width - 50f, 74f, settingsStroke)
+
+ roundRect(34f, 168f, width - 34f, 326f, 72f, cardPaint)
+
+ val gPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE; strokeWidth = 12f; strokeCap = Paint.Cap.ROUND
+ }
+ val gx = 112f; val gy = 247f; val gr = 30f
+ gPaint.color = Color.parseColor("#EA4335"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr, 20f, 82f, false, gPaint)
+ gPaint.color = Color.parseColor("#FBBC05"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr,102f, 78f, false, gPaint)
+ gPaint.color = Color.parseColor("#34A853"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr,180f, 80f, false, gPaint)
+ gPaint.color = Color.parseColor("#4285F4"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr,260f, 74f, false, gPaint)
+ canvas.drawText("Search", 184f, 258f, textPaint)
+
+ fun iconTile(x: Float, y: Float) { roundRect(x, y, x+58f, y+58f, 18f, accentPaint) }
+ iconTile(width - 222f, 221f); iconTile(width - 144f, 221f)
+
+ val qrX = width - 204f; val qrY = 237f
+ val qrPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
+ repeat(3) { row -> repeat(3) { col ->
+ canvas.drawRect(qrX+col*10f, qrY+row*10f, qrX+col*10f+7f, qrY+row*10f+7f, qrPaint)
+ }}
+ canvas.drawRect(qrX+28f, qrY+10f, qrX+33f, qrY+15f, qrPaint)
+ canvas.drawRect(qrX+20f, qrY+28f, qrX+25f, qrY+33f, qrPaint)
+
+ val micX = width - 122f
+ val micPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE; style = Paint.Style.STROKE
+ strokeWidth = 7f; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
+ }
+ canvas.drawRoundRect(RectF(micX+16f, 234f, micX+38f, 272f), 11f, 11f, micPaint)
+ canvas.drawArc(RectF(micX+11f, 232f, micX+43f, 270f), 0f, 180f, false, micPaint)
+ canvas.drawLine(micX+27f, 272f, micX+27f, 289f, micPaint)
+ canvas.drawLine(micX+16f, 289f, micX+38f, 289f, micPaint)
+
+ canvas.drawText("Bookmarks", 40f, 406f, mutedPaint)
+
+ val sample = listOf(
+ Triple("GitHub", Color.parseColor("#FFFFFF"), "GH"),
+ Triple("Stack Overflow",Color.parseColor("#FFFFFF"), "SO"),
+ Triple("MDN", Color.parseColor("#FFFFFF"), "MDN"),
+ Triple("npm", Color.parseColor("#C63636"), "npm"),
+ Triple("Docker", Color.parseColor("#2496ED"), "D"),
+ Triple("Dev.to", Color.parseColor("#FFFFFF"), "DEV")
+ )
+ val lefts = floatArrayOf(36f, 206f, 376f)
+ val tops = floatArrayOf(446f, 650f)
+ var idx = 0
+ for (row in tops) {
+ for (col in lefts) {
+ if (idx >= sample.size) break
+ val (name, bg, txt) = sample[idx++]
+ val tilePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = bg }
+ roundRect(col, row, col+96f, row+96f, 26f, tilePaint)
+ val textColor = if (name == "npm") Color.WHITE else Color.BLACK
+ val lp = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = textColor; textAlign = Paint.Align.CENTER
+ textSize = if (txt.length > 2) 22f else 28f
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }
+ canvas.drawText(txt, col+48f, row+58f, lp)
+ canvas.drawText(name, col+48f, row+126f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 19f
+ })
+ }
+ }
+ return bitmap
+}
+
+internal fun createMicBitmap(sizePx: Int): Bitmap {
+ val size = maxOf(sizePx, 48)
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE; style = Paint.Style.STROKE
+ strokeWidth = maxOf(4.2f, size * 0.11f)
+ strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
+ }
+ val w = size.toFloat(); val h = size.toFloat()
+ canvas.drawRoundRect(RectF(w*0.34f, h*0.12f, w*0.66f, h*0.64f), w*0.16f, w*0.16f, paint)
+ canvas.drawLine(w*0.5f, h*0.64f, w*0.5f, h*0.78f, paint)
+ canvas.drawLine(w*0.35f, h*0.78f, w*0.65f, h*0.78f, paint)
+ canvas.drawArc(RectF(w*0.28f, h*0.10f, w*0.72f, h*0.66f), 200f, 140f, false, paint)
+ return bitmap
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/CursorController.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/CursorController.kt
new file mode 100644
index 0000000..13f9d52
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/CursorController.kt
@@ -0,0 +1,332 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.view.Choreographer
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.WebView
+import android.widget.FrameLayout
+import kotlin.math.abs
+
+/**
+ * CursorController — مؤشر افتراضي تحركه عصا التحكم اليسار.
+ *
+ * آلية العمل:
+ * 1. InputController يُغذّيه بقيم المحور عبر updateStick(x, y).
+ * 2. Choreographer.FrameCallback يُحدّث posX/posY مرة لكل VSYNC
+ * بمنحنى سرعة تربيعي: بطيء عند المركز، سريع عند الحافة.
+ * 3. CursorView حجم ثابت، يُحرَّك بـ translationX/Y فقط —
+ * محتواه يُرسم مرة واحدة ويُركَّب على GPU بدون أي تكلفة CPU.
+ * 4. performClick() يُرسل ACTION_DOWN + ACTION_UP حقيقيَّين عبر
+ * Activity.dispatchTouchEvent بإحداثيات المؤشر، فيصل الحدث
+ * لأي View أو WebView يقع تحت المؤشر تماماً كالنقر الحقيقي.
+ * 5. CursorView يُعيد false من onTouchEvent فلا يحجب أي نقرة.
+ *
+ * يختفي تلقائياً بعد 3.5 ثانية من توقف العصا.
+ */
+class CursorController(private val activity: Activity) {
+
+ private val d = activity.resources.displayMetrics.density
+ private val RADIUS = 12f * d // نصف قطر الدائرة بالبكسل
+ private val SIZE = (RADIUS * 2f + 0.5f).toInt() // حجم الـ View بالبكسل
+
+ val view = CursorView(activity)
+
+ private val contentRoot: View
+ get() = activity.findViewById(android.R.id.content)
+
+ private var posX = 0f
+ private var posY = 0f
+ private var screenW = 0f
+ private var screenH = 0f
+
+ // قيم المحور الحالية (تُكتب وتُقرأ على Main thread فقط)
+ private var axisX = 0f
+ private var axisY = 0f
+
+ private val choreo = Choreographer.getInstance()
+ private var framePosted = false
+
+ private val hideHandler = Handler(Looper.getMainLooper())
+ private val hideTask = Runnable { fadeOut() }
+
+ // ── دورة الحياة ───────────────────────────────────────────────────────
+
+ fun attach() {
+ val decor = activity.window.decorView as ViewGroup
+ decor.addView(view, FrameLayout.LayoutParams(SIZE, SIZE))
+ // نُحدّد أبعاد الشاشة بعد انتهاء layout pass
+ decor.post {
+ screenW = decor.width.toFloat()
+ screenH = decor.height.toFloat()
+ posX = screenW / 2f
+ posY = screenH / 2f
+ applyPosition()
+ }
+ }
+
+ fun detach() {
+ choreo.removeFrameCallback(frameCallback)
+ hideHandler.removeCallbacks(hideTask)
+ (activity.window.decorView as? ViewGroup)?.removeView(view)
+ }
+
+ // ── واجهة المدخلات ────────────────────────────────────────────────────
+
+ /** يُستدعى من InputController بقيم عصا اليسار بعد معالجة dead zone. */
+ fun updateStick(x: Float, y: Float) {
+ axisX = x
+ axisY = y
+ if (x != 0f || y != 0f) {
+ revealCursor()
+ scheduleFrame()
+ }
+ }
+
+ /** يُرسل نقرة حقيقية على موضع المؤشر. يُعيد false إن كان المؤشر مخفياً. */
+ fun performClick(): Boolean {
+ if (!isVisible) return false
+
+ // نجعل المؤشر غير مرئي فقط أثناء إرسال النقرة حتى لا يلتقط
+ // هو نفسه اللمسة، ثم نعيده مباشرةً بعد الإرسال.
+ hideHandler.removeCallbacks(hideTask)
+ view.animate().cancel()
+
+ val wasVisible = view.visibility == View.VISIBLE
+ if (wasVisible) {
+ view.alpha = 0f
+ view.visibility = View.INVISIBLE
+ }
+
+ val handled = dispatchSystemTap(contentRoot, posX, posY)
+
+ if (wasVisible) {
+ view.visibility = View.VISIBLE
+ view.alpha = 1f
+ }
+ view.animateClick()
+ hideHandler.postDelayed(hideTask, HIDE_DELAY_MS)
+ return handled
+ }
+
+ /** يرسل نقرة نظامية مباشرة إلى العنصر الواقع تحت المؤشر. */
+ private fun dispatchSystemTap(root: View, screenX: Float, screenY: Float): Boolean {
+ val target = findTargetView(root, screenX, screenY) ?: return false
+
+ val loc = IntArray(2)
+ target.getLocationOnScreen(loc)
+ val localX = screenX - loc[0]
+ val localY = screenY - loc[1]
+ if (target.width <= 0 || target.height <= 0) return false
+ if (localX < 0f || localY < 0f || localX > target.width.toFloat() || localY > target.height.toFloat()) {
+ return false
+ }
+
+ if (target.isFocusable) {
+ target.requestFocus()
+ }
+
+ val t = SystemClock.uptimeMillis()
+ val dn = MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, localX, localY, 0)
+ val up = MotionEvent.obtain(t, t + 80L, MotionEvent.ACTION_UP, localX, localY, 0)
+
+ val downHandled = target.dispatchTouchEvent(dn)
+ val upHandled = target.dispatchTouchEvent(up)
+ dn.recycle()
+ up.recycle()
+
+ return downHandled || upHandled || target.performClick()
+ }
+
+ /** يبحث عن أعمق View قابلة للتفاعل تحت إحداثيات الشاشة المعطاة. */
+ private fun findTargetView(view: View, screenX: Float, screenY: Float): View? {
+ if (view === this.view) return null
+ if (view.visibility != View.VISIBLE || view.alpha <= 0.05f) return null
+
+ val loc = IntArray(2)
+ view.getLocationOnScreen(loc)
+ val left = loc[0].toFloat()
+ val top = loc[1].toFloat()
+ val right = left + view.width.toFloat()
+ val bottom = top + view.height.toFloat()
+
+ if (screenX < left || screenX > right || screenY < top || screenY > bottom) return null
+
+ if (view is ViewGroup) {
+ for (i in view.childCount - 1 downTo 0) {
+ val child = view.getChildAt(i)
+ val hit = findTargetView(child, screenX, screenY)
+ if (hit != null) return hit
+ }
+ }
+
+ return if (isTargetable(view)) view else null
+ }
+
+ private fun isTargetable(view: View): Boolean {
+ return view is WebView ||
+ view.isClickable ||
+ view.isLongClickable ||
+ view.hasOnClickListeners() ||
+ view is android.widget.EditText
+ }
+
+ /** ينعش المؤشر ويمنعه من الاختفاء عند تبديل التبويبات أو القوائم. */
+ fun keepVisible() {
+ revealCursor()
+ }
+
+ /** مركز المؤشر الحالي على الشاشة، بوحدة البكسل. */
+ fun screenPosition(): Pair = posX to posY
+
+ val isVisible: Boolean
+ get() = view.visibility == View.VISIBLE && view.alpha > 0.05f
+
+ // ── حلقة Choreographer ────────────────────────────────────────────────
+
+ private val frameCallback = Choreographer.FrameCallback {
+ framePosted = false
+ if (axisX != 0f || axisY != 0f) {
+ advance()
+ scheduleFrame()
+ }
+ }
+
+ private fun scheduleFrame() {
+ if (!framePosted) {
+ framePosted = true
+ choreo.postFrameCallback(frameCallback)
+ }
+ }
+
+ /** يُقدّم المؤشر خطوة واحدة بناءً على قيم العصا الحالية. */
+ private fun advance() {
+ val maxPx = 20f * d // أقصى سرعة بالبكسل/إطار عند قيمة ±1.0
+ // منحنى تربيعي: sign(v)·v²·maxPx
+ posX = (posX + axisX * abs(axisX) * maxPx).coerceIn(RADIUS, screenW - RADIUS)
+ posY = (posY + axisY * abs(axisY) * maxPx).coerceIn(RADIUS, screenH - RADIUS)
+ applyPosition()
+ }
+
+ /** يضع الـ View بحيث يكون مركز الدائرة عند (posX, posY). */
+ private fun applyPosition() {
+ view.translationX = posX - RADIUS
+ view.translationY = posY - RADIUS
+ }
+
+ // ── الظهور والاختفاء ──────────────────────────────────────────────────
+
+ private fun revealCursor() {
+ hideHandler.removeCallbacks(hideTask)
+ if (view.visibility != View.VISIBLE) {
+ view.alpha = 0f
+ view.visibility = View.VISIBLE
+ }
+ view.animate().cancel()
+ view.animate().alpha(1f).setDuration(150L).start()
+ hideHandler.postDelayed(hideTask, HIDE_DELAY_MS)
+ }
+
+ private fun fadeOut() {
+ view.animate().cancel()
+ view.animate().alpha(0f).setDuration(400L)
+ .withEndAction { view.visibility = View.INVISIBLE }
+ .start()
+ }
+
+ // ── CursorView ────────────────────────────────────────────────────────
+ //
+ // • حجم ثابت (SIZE × SIZE px).
+ // • يُحرَّك بـ translationX/Y — لا invalidate() لكل إطار.
+ // • onTouchEvent يُعيد false → النقرات تمر للـ Views التي تحته.
+
+ inner class CursorView(ctx: Context) : View(ctx) {
+ private val den = ctx.resources.displayMetrics.density
+ private val r = 12f * den // نصف قطر الدائرة
+ private val sw = 2.2f * den // سماكة البوردر
+
+ // دائرة شفافة ببوردر أبيض
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ style = Paint.Style.STROKE
+ strokeWidth = sw
+ }
+
+ // نقطة مركز بيضاء للدقة في التوجيه
+ private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.argb(180, 255, 255, 255)
+ style = Paint.Style.FILL
+ }
+
+ // ومضة داخلية عند النقر
+ private val flashPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ style = Paint.Style.FILL
+ alpha = 0
+ }
+ private var flashAlpha = 0
+ private var flashAnim: android.animation.ValueAnimator? = null
+
+ init {
+ isClickable = false
+ isFocusable = false
+ visibility = View.INVISIBLE
+ alpha = 0f
+ setLayerType(LAYER_TYPE_HARDWARE, null)
+ }
+
+ /** ومضة بيضاء داخلية سريعة تؤكد النقر بصرياً. */
+ fun animateClick() {
+ flashAnim?.cancel()
+ flashAnim = android.animation.ValueAnimator.ofInt(220, 0).apply {
+ duration = 260L
+ interpolator = android.view.animation.DecelerateInterpolator(1.5f)
+ addUpdateListener { anim ->
+ flashAlpha = anim.animatedValue as Int
+ invalidate()
+ }
+ addListener(object : android.animation.AnimatorListenerAdapter() {
+ override fun onAnimationEnd(a: android.animation.Animator) {
+ flashAlpha = 0
+ invalidate()
+ }
+ })
+ start()
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ val cx = width / 2f
+ val cy = height / 2f
+ val innerR = r - sw / 2f
+
+ // 1. ومضة داخلية (شفاف عادةً، أبيض لحظة النقر)
+ if (flashAlpha > 0) {
+ flashPaint.alpha = flashAlpha
+ canvas.drawCircle(cx, cy, innerR, flashPaint)
+ }
+
+ // 2. بوردر أبيض
+ canvas.drawCircle(cx, cy, innerR, borderPaint)
+
+ // 3. نقطة مركز للدقة
+ canvas.drawCircle(cx, cy, 2.4f * den, dotPaint)
+ }
+
+ override fun onTouchEvent(e: MotionEvent) = false
+ }
+
+ // ── ثوابت ─────────────────────────────────────────────────────────────
+
+ private companion object {
+ const val HIDE_DELAY_MS = 3500L
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/InputController.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/InputController.kt
new file mode 100644
index 0000000..53d5bc0
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/InputController.kt
@@ -0,0 +1,359 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.webkit.WebView
+import kotlin.math.abs
+
+/**
+ * InputController — مركز إدارة كل المدخلات في ConsoleFlow.
+ *
+ * المصادر المدعومة:
+ * ┌────────────────┬──────────────────────────────────────────────────────┐
+ * │ ريموت تلفزيون │ D-pad · OK/Back · Menu · Channel+/− · Page Up/Down │
+ * │ جويستيك │ A/B/X/Y · L1/R1/L2/R2 · Start/Select · L3/R3 │
+ * │ │ عصا يسار → cursor · عصا يمين → تمرير │
+ * │ كيبورد │ Ctrl+L/T/W/R/F/Tab · Alt+←/→ · F5/F12 · Escape │
+ * │ ماوس │ عجلة التمرير (عمودي + أفقي) │
+ * └────────────────┴──────────────────────────────────────────────────────┘
+ *
+ * قواعد ثابتة:
+ * • event.repeatCount != 0 → يُتجاهل (لا تكرار عند الضغط المستمر)
+ * • KEYCODE_BACK → لا يُستهلَك أبداً (يذهب لـ BackPressedDispatcher)
+ * • getCenteredAxis → النمط الرسمي من توثيق Android بـ range.flat
+ * • isFromSource() → الطريقة الرسمية بدل bitwise AND اليدوي
+ */
+class InputController(private val h: Handlers) {
+
+ interface Handlers {
+ fun getWebView(): WebView?
+ fun isTopBarVisible(): Boolean
+ fun isTabsOverlayVisible(): Boolean
+ fun showTopBar()
+ fun hideTopBar()
+ fun focusUrlBar()
+ fun navigateBack()
+ fun navigateForward()
+ fun navigateHome()
+ fun openNewTab()
+ fun closeCurrentTab()
+ fun reload()
+ fun showMenu()
+ fun showTabs()
+ fun nextTab()
+ fun prevTab()
+ fun toggleConsole()
+ fun toggleFind()
+ fun dismissTopOverlay(): Boolean
+ }
+
+ // ── عصا يمين: تمرير عبر Handler loop (~60fps) ─────────────────────────
+ private val handler = Handler(Looper.getMainLooper())
+ private var scrollX = 0f
+ private var scrollY = 0f
+ private var scrollJob: Runnable? = null
+
+ // ── عصا يسار: مؤشر افتراضي ─────────────────────────────────────────────
+ private var cursor: CursorController? = null
+ fun setCursorController(c: CursorController) { cursor = c }
+
+ /** هل يوجد overlay يحجب WebView حالياً؟ */
+ private fun overlayActive() = h.isTopBarVisible() || h.isTabsOverlayVisible()
+
+ // ─────────────────────────────────────────────────────────────────────
+ // نقاط الدخول (تُستدعى من Activity)
+ // ─────────────────────────────────────────────────────────────────────
+
+ /** يُستدعى من Activity.dispatchKeyEvent(). يعيد true إن استهلك الحدث. */
+ fun onKeyDown(event: KeyEvent): Boolean {
+ if (event.action != KeyEvent.ACTION_DOWN) return false
+ if (event.repeatCount != 0) return false // تجاهل التكرار عند الضغط المستمر
+
+ return when {
+ // أزرار جويستيك (غير D-pad): تعيين خاص
+ event.isFromSource(InputDevice.SOURCE_GAMEPAD)
+ && event.keyCode !in DPAD_KEYS -> handleGamepad(event)
+
+ event.isCtrlPressed -> handleCtrl(event)
+ event.isAltPressed -> handleAlt(event)
+ else -> handleGenericKey(event)
+ }
+ }
+
+ /** يُستدعى من Activity.onGenericMotionEvent(). */
+ fun onGenericMotion(event: MotionEvent): Boolean = when {
+ event.isFromSource(InputDevice.SOURCE_JOYSTICK)
+ && event.action == MotionEvent.ACTION_MOVE -> handleJoystick(event)
+
+ event.isFromSource(InputDevice.SOURCE_MOUSE)
+ && event.action == MotionEvent.ACTION_SCROLL -> handleMouseWheel(event)
+
+ else -> false
+ }
+
+ /** يُستدعى من Activity.onDestroy(). */
+ fun release() {
+ stopScrollLoop()
+ cursor = null
+ }
+
+ /** يوقف تمرير العصا اليمنى فورًا حتى لا يظل عالقًا بعد نقرة أو تبديل واجهة. */
+ fun stopScrollLoop() {
+ scrollJob?.let { handler.removeCallbacks(it) }
+ scrollJob = null
+ scrollX = 0f
+ scrollY = 0f
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // أزرار الجويستيك
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun handleGamepad(event: KeyEvent): Boolean = when (event.keyCode) {
+
+ KeyEvent.KEYCODE_BUTTON_A -> {
+ // إن كان المؤشر ظاهراً → انقر عنده. وإلا → نشّط عنصر الصفحة.
+ when {
+ cursor?.isVisible == true -> {
+ val handled = performCursorClick()
+ cursor?.keepVisible()
+ handled
+ }
+ !overlayActive() -> {
+ activateWebElement()
+ cursor?.keepVisible()
+ true
+ }
+ else -> true
+ }
+ }
+
+ // BUTTON_B → لا يُستهلَك. يصل إلى onBackPressedDispatcher.
+ KeyEvent.KEYCODE_BUTTON_B -> false
+
+ KeyEvent.KEYCODE_BUTTON_X -> { h.showTabs(); true }
+ KeyEvent.KEYCODE_BUTTON_Y -> { h.showMenu(); true }
+ KeyEvent.KEYCODE_BUTTON_L1 -> { h.prevTab(); true }
+ KeyEvent.KEYCODE_BUTTON_R1 -> { h.nextTab(); true }
+ KeyEvent.KEYCODE_BUTTON_L2 -> { h.navigateBack(); true }
+ KeyEvent.KEYCODE_BUTTON_R2 -> { h.navigateForward();true }
+ KeyEvent.KEYCODE_BUTTON_START -> { h.showMenu(); true }
+ KeyEvent.KEYCODE_BUTTON_SELECT -> { h.toggleConsole(); true }
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> { h.navigateHome(); true }
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> { h.focusUrlBar(); true }
+
+ else -> false
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // اختصارات الكيبورد
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun handleCtrl(event: KeyEvent): Boolean = when (event.keyCode) {
+ KeyEvent.KEYCODE_L -> { h.focusUrlBar(); true }
+ KeyEvent.KEYCODE_T -> { h.openNewTab(); true }
+ KeyEvent.KEYCODE_W -> { h.closeCurrentTab(); true }
+ KeyEvent.KEYCODE_R -> { h.reload(); true }
+ KeyEvent.KEYCODE_F -> { h.toggleFind(); true }
+ KeyEvent.KEYCODE_TAB -> {
+ if (event.isShiftPressed) h.prevTab() else h.nextTab()
+ true
+ }
+ else -> false
+ }
+
+ private fun handleAlt(event: KeyEvent): Boolean = when (event.keyCode) {
+ KeyEvent.KEYCODE_DPAD_LEFT -> { h.navigateBack(); true }
+ KeyEvent.KEYCODE_DPAD_RIGHT -> { h.navigateForward(); true }
+ else -> false
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // D-pad / ريموت / أسهم الكيبورد / مفاتيح أخرى
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun handleGenericKey(event: KeyEvent): Boolean {
+ val wv by lazy { h.getWebView() }
+
+ return when (event.keyCode) {
+
+ KeyEvent.KEYCODE_DPAD_UP -> when {
+ overlayActive() -> false
+ wv != null && !wv!!.canScrollVertically(-1) -> { h.showTopBar(); true }
+ else -> false // WebView تتمرر بنفسها
+ }
+
+ KeyEvent.KEYCODE_DPAD_DOWN -> when {
+ h.isTopBarVisible() -> { h.hideTopBar(); true }
+ else -> false
+ }
+
+ KeyEvent.KEYCODE_DPAD_LEFT -> {
+ if (overlayActive()) return false
+ val v = wv ?: return false
+ when {
+ v.canScrollHorizontally(-1) -> { v.scrollBy(-DPAD_SCROLL, 0); true }
+ v.canGoBack() -> { h.navigateBack(); true }
+ else -> false
+ }
+ }
+
+ KeyEvent.KEYCODE_DPAD_RIGHT -> {
+ if (overlayActive()) return false
+ val v = wv ?: return false
+ when {
+ v.canScrollHorizontally(1) -> { v.scrollBy(DPAD_SCROLL, 0); true }
+ v.canGoForward() -> { h.navigateForward(); true }
+ else -> false
+ }
+ }
+
+ KeyEvent.KEYCODE_DPAD_CENTER -> {
+ if (overlayActive()) false else { activateWebElement(); true }
+ }
+
+ KeyEvent.KEYCODE_F5 -> { h.reload(); true }
+ KeyEvent.KEYCODE_F12 -> { h.toggleConsole(); true }
+ KeyEvent.KEYCODE_ESCAPE -> h.dismissTopOverlay()
+ KeyEvent.KEYCODE_MENU -> { h.showMenu(); true }
+ KeyEvent.KEYCODE_PAGE_UP -> { wv?.scrollBy(0, -PAGE_SCROLL); true }
+ KeyEvent.KEYCODE_PAGE_DOWN -> { wv?.scrollBy(0, PAGE_SCROLL); true }
+ KeyEvent.KEYCODE_CHANNEL_UP -> { h.nextTab(); true }
+ KeyEvent.KEYCODE_CHANNEL_DOWN -> { h.prevTab(); true }
+
+ else -> false
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Joystick — نمط المعالجة الرسمي من توثيق Android
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun handleJoystick(event: MotionEvent): Boolean {
+ val device = event.device ?: return false
+
+ // الأمر الرسمي: اعالج القيم التاريخية أولاً ثم القيمة الحالية
+ for (i in 0 until event.historySize) {
+ processJoystickInput(event, device, i)
+ }
+ processJoystickInput(event, device, -1)
+ return true
+ }
+
+ private fun processJoystickInput(event: MotionEvent, device: InputDevice, histPos: Int) {
+ // عصا يسار → المؤشر الافتراضي
+ val lx = getCenteredAxis(event, device, MotionEvent.AXIS_X, histPos)
+ val ly = getCenteredAxis(event, device, MotionEvent.AXIS_Y, histPos)
+ cursor?.updateStick(lx, ly)
+
+ // أي حركة من الجويستيك تبقي المؤشر حياً أثناء التنقّل بين الواجهات.
+ cursor?.keepVisible()
+
+ // عصا يمين → تمرير الصفحة؛ الاحتياط بـ HAT axes إن كانت عصا اليمين في مركزها
+ var rx = getCenteredAxis(event, device, MotionEvent.AXIS_Z, histPos)
+ var ry = getCenteredAxis(event, device, MotionEvent.AXIS_RZ, histPos)
+ if (rx == 0f && ry == 0f) {
+ rx = getCenteredAxis(event, device, MotionEvent.AXIS_HAT_X, histPos)
+ ry = getCenteredAxis(event, device, MotionEvent.AXIS_HAT_Y, histPos)
+ }
+ setScrollAxes(rx, ry)
+ }
+
+ /**
+ * getCenteredAxis — التطبيق الرسمي من توثيق Android.
+ *
+ * يستخدم قيمة flat المُبلَّغ عنها من الجهاز نفسه، لذلك تعمل dead zone
+ * بشكل صحيح مع كل أنواع الكنترولرز بغض النظر عن الماركة.
+ */
+ private fun getCenteredAxis(
+ event: MotionEvent, device: InputDevice, axis: Int, histPos: Int
+ ): Float {
+ val range = device.getMotionRange(axis, event.source) ?: return 0f
+ val value = if (histPos < 0) event.getAxisValue(axis)
+ else event.getHistoricalAxisValue(axis, histPos)
+ return if (abs(value) > range.flat) value else 0f
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // حلقة تمرير عصا اليمين
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun setScrollAxes(x: Float, y: Float) {
+ scrollX = x
+ scrollY = y
+ if ((x != 0f || y != 0f) && scrollJob == null) startScrollLoop()
+ }
+
+ private fun startScrollLoop() {
+ scrollJob = object : Runnable {
+ override fun run() {
+ if (scrollX == 0f && scrollY == 0f) {
+ scrollJob = null
+ return
+ }
+
+ h.getWebView()?.scrollBy(
+ (scrollX * SCROLL_SPEED).toInt(),
+ (scrollY * SCROLL_SPEED).toInt()
+ )
+
+ handler.postDelayed(this, SCROLL_TICK_MS)
+ }
+ }
+ handler.post(scrollJob!!)
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // عجلة الماوس
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun handleMouseWheel(event: MotionEvent): Boolean {
+ val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
+ val hz = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
+ if (v == 0f && hz == 0f) return false
+ h.getWebView()?.scrollBy((-hz * MOUSE_SPEED).toInt(), (-v * MOUSE_SPEED).toInt())
+ return true
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // تنشيط عنصر الصفحة (زر OK / A)
+ // ─────────────────────────────────────────────────────────────────────
+
+ private fun performCursorClick(): Boolean {
+ val c = cursor ?: return false
+ return c.performClick()
+ }
+
+ private fun activateWebElement() {
+ val wv = h.getWebView() ?: return
+ stopScrollLoop()
+ wv.requestFocus()
+ val t = SystemClock.uptimeMillis()
+ wv.dispatchKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER, 0))
+ wv.dispatchKeyEvent(KeyEvent(t, t + 50L, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER, 0))
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // ثوابت
+ // ─────────────────────────────────────────────────────────────────────
+
+ private companion object {
+ const val DPAD_SCROLL = 200 // px لكل ضغطة D-pad أفقي
+ const val PAGE_SCROLL = 720 // px لـ Page Up/Down
+ const val SCROLL_SPEED = 18f // px/tick لتمرير عصا اليمين
+ const val MOUSE_SPEED = 80f // px لكل نقرة عجلة ماوس
+ const val SCROLL_TICK_MS = 16L // ~60fps
+
+ val DPAD_KEYS = setOf(
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_DPAD_CENTER
+ )
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/PrefsManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/PrefsManager.kt
index 46eeab9..e776cf0 100644
--- a/app/src/main/java/space/karrarnazim/ConsoleFlow/PrefsManager.kt
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/PrefsManager.kt
@@ -1,119 +1,3 @@
package space.karrarnazim.ConsoleFlow
-import android.content.Context
-import android.content.SharedPreferences
-import org.json.JSONArray
-import org.json.JSONObject
-
-class PrefsManager(context: Context) {
- private val prefs: SharedPreferences = context.getSharedPreferences("ConsoleFlowPrefs", Context.MODE_PRIVATE)
-
- var desktopMode: Boolean
- get() = prefs.getBoolean("desktop_mode", false)
- set(value) = prefs.edit().putBoolean("desktop_mode", value).apply()
-
- var searchEngine: String
- get() = prefs.getString("search_engine", "https://www.google.com/search?q=")!!
- set(value) = prefs.edit().putString("search_engine", value).apply()
-
- var searchEngineIsCustom: Boolean
- get() = prefs.getBoolean("search_engine_is_custom", false)
- set(value) = prefs.edit().putBoolean("search_engine_is_custom", value).apply()
-
- var customJs: String
- get() = prefs.getString("custom_js", "")!!
- set(value) = prefs.edit().putString("custom_js", value).apply()
-
- var consoleEnabled: Boolean
- get() = prefs.getBoolean("console_enabled", false)
- set(value) = prefs.edit().putBoolean("console_enabled", value).apply()
-
- // ✅ الدالة المضافة حديثًا لحل خطأ "Unresolved reference: getBoolean"
- fun getBoolean(key: String, defaultValue: Boolean): Boolean {
- return prefs.getBoolean(key, defaultValue)
- }
-
- fun addHistory(title: String, url: String) {
- if (url == "about:blank" || url == "error://page" || url.startsWith("error://")) return
- val historyArray = getList("history")
- val newItem = JSONObject().apply {
- put("title", title)
- put("url", url)
- }
- for (i in 0 until historyArray.length()) {
- val old = historyArray.optJSONObject(i) ?: continue
- if (old.optString("url") == url) {
- historyArray.remove(i)
- break
- }
- }
- historyArray.put(newItem)
- while (historyArray.length() > 100) historyArray.remove(0)
- prefs.edit().putString("history", historyArray.toString()).apply()
- }
-
- fun clearHistory() = prefs.edit().remove("history").apply()
-
- fun toggleBookmark(title: String, url: String): Boolean {
- val bookmarks = getList("bookmarks")
- var exists = false
- var indexToRemove = -1
-
- for (i in 0 until bookmarks.length()) {
- val old = bookmarks.optJSONObject(i) ?: continue
- if (old.optString("url") == url) {
- exists = true
- indexToRemove = i
- break
- }
- }
-
- if (exists) {
- bookmarks.remove(indexToRemove)
- } else {
- bookmarks.put(JSONObject().apply {
- put("title", title)
- put("url", url)
- })
- }
- prefs.edit().putString("bookmarks", bookmarks.toString()).apply()
- return !exists
- }
-
- fun isBookmarked(url: String): Boolean {
- val bookmarks = getList("bookmarks")
- for (i in 0 until bookmarks.length()) {
- if (bookmarks.optJSONObject(i)?.optString("url") == url) return true
- }
- return false
- }
-
- fun getBookmarks(): List> {
- val bookmarks = getList("bookmarks")
- val list = mutableListOf>()
- for (i in 0 until bookmarks.length()) {
- try {
- val obj = bookmarks.optJSONObject(i) ?: JSONObject(bookmarks.getString(i))
- list.add(Pair(obj.getString("title"), obj.getString("url")))
- } catch (e: Exception) { }
- }
- return list
- }
-
- fun getHistory(): List> {
- val history = getList("history")
- val list = mutableListOf>()
- for (i in history.length() - 1 downTo 0) {
- try {
- val obj = history.optJSONObject(i) ?: JSONObject(history.getString(i))
- list.add(Pair(obj.getString("title"), obj.getString("url")))
- } catch (e: Exception) { }
- }
- return list
- }
-
- private fun getList(key: String): JSONArray {
- val jsonStr = prefs.getString(key, "[]")
- return try { JSONArray(jsonStr) } catch (e: Exception) { JSONArray() }
- }
-}
\ No newline at end of file
+class PrefsManager(context: android.content.Context) : SettingsRepository(context)
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/SettingsActivity.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/SettingsActivity.kt
deleted file mode 100644
index e99402e..0000000
--- a/app/src/main/java/space/karrarnazim/ConsoleFlow/SettingsActivity.kt
+++ /dev/null
@@ -1,261 +0,0 @@
-package space.karrarnazim.ConsoleFlow
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.text.InputType
-import android.util.TypedValue
-import android.view.Gravity
-import android.view.View
-import android.view.ViewGroup
-import android.widget.BaseAdapter
-import android.widget.EditText
-import android.widget.ImageView
-import android.widget.LinearLayout
-import android.widget.ListView
-import android.widget.TextView
-import android.widget.Toast
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.app.AppCompatActivity
-import androidx.appcompat.widget.SwitchCompat
-
-class SettingsActivity : AppCompatActivity() {
-
- private lateinit var prefsManager: PrefsManager
-
- private data class EngineOption(val name: String, val url: String, val iconRes: Int?)
-
- private val engineOptions = listOf(
- EngineOption("Google", "https://www.google.com/search?q=", R.drawable.ic_engine_google),
- EngineOption("DuckDuckGo", "https://duckduckgo.com/?q=", R.drawable.ic_engine_duckduckgo),
- EngineOption("Bing", "https://www.bing.com/search?q=", R.drawable.ic_engine_bing),
- EngineOption("Brave", "https://search.brave.com/search?q=", R.drawable.ic_engine_brave),
- EngineOption("Custom", "", null)
- )
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_settings)
- prefsManager = PrefsManager(this)
-
- findViewById(R.id.btnBack).setOnClickListener { finish() }
-
- try {
- val pInfo = packageManager.getPackageInfo(packageName, 0)
- findViewById(R.id.settingVersion).text = "Version ${pInfo.versionName}"
- } catch (_: Exception) {}
-
- updateSearchEngineValue()
- findViewById(R.id.settingSearchEngine).setOnClickListener {
- showSearchEnginePicker()
- }
-
- val switchDesktop = findViewById(R.id.switchDesktopMode)
- switchDesktop.isChecked = prefsManager.desktopMode
- switchDesktop.setOnCheckedChangeListener { _, isChecked ->
- prefsManager.desktopMode = isChecked
- }
- findViewById(R.id.settingDesktopMode).setOnClickListener {
- switchDesktop.isChecked = !switchDesktop.isChecked
- }
-
- findViewById(R.id.settingCustomJs).setOnClickListener {
- val input = EditText(this).apply {
- setText(prefsManager.customJs)
- setTextColor(0xFFFFFFFF.toInt())
- setBackgroundColor(0xFF1A1A1A.toInt())
- setPadding(32, 24, 32, 24)
- hint = "// Your JavaScript here..."
- setHintTextColor(0xFF444444.toInt())
- isSingleLine = false
- minLines = 5
- }
- AlertDialog.Builder(this, R.style.DarkDialog)
- .setTitle("Custom JavaScript")
- .setView(input)
- .setPositiveButton("Save") { _, _ ->
- prefsManager.customJs = input.text.toString()
- Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
- }
- .setNegativeButton("Cancel", null)
- .show()
- }
-
- findViewById(R.id.settingClearData).setOnClickListener {
- AlertDialog.Builder(this, R.style.DarkDialog)
- .setTitle("Clear Browsing Data")
- .setMessage("This will delete cache, cookies, and history.")
- .setPositiveButton("Clear") { _, _ ->
- android.webkit.WebStorage.getInstance().deleteAllData()
- val cookies = android.webkit.CookieManager.getInstance()
- cookies.removeAllCookies(null)
- cookies.flush()
- prefsManager.clearHistory()
- getSharedPreferences("ConsoleFlowPrefs", Context.MODE_PRIVATE)
- .edit()
- .remove("SAVED_GROUPS")
- .remove("ACTIVE_GROUP")
- .remove("ACTIVE_TAB")
- .remove("NEXT_TAB_ID")
- .remove("NEXT_GROUP_ID")
- .apply()
- cacheDir.listFiles()?.forEach { file ->
- if (file.name.startsWith("thumb_") && file.name.endsWith(".webp")) file.delete()
- if (file.name.startsWith("home_preview_") && (file.name.endsWith(".webp") || file.name.endsWith(".sig"))) file.delete()
- }
- Toast.makeText(this, "Data cleared", Toast.LENGTH_SHORT).show()
- }
- .setNegativeButton("Cancel", null)
- .show()
- }
-
- findViewById(R.id.settingPortfolio).setOnClickListener {
- openUrl("https://karrarnazim.space")
- }
-
- findViewById(R.id.settingAppWebsite).setOnClickListener {
- openUrl("https://consoleflow.karrarnazim.space")
- }
-
- findViewById(R.id.settingPrivacyPolicy).setOnClickListener {
- openUrl("https://consoleflow.karrarnazim.space/privacy")
- }
-
- findViewById(R.id.settingOpenSource).setOnClickListener {
- openUrl("https://github.com/SANDRO00O/ConsoleFlow-mobile")
- }
- }
-
- private fun updateSearchEngineValue() {
- findViewById(R.id.settingSearchEngineValue).text = if (prefsManager.searchEngineIsCustom) "Custom" else searchEngineDisplayName(prefsManager.searchEngine)
- }
-
- private fun showSearchEnginePicker() {
- val listView = ListView(this).apply {
- divider = null
- setPadding(24, 20, 24, 20)
- clipToPadding = false
- }
-
- val adapter = object : BaseAdapter() {
- override fun getCount() = engineOptions.size
- override fun getItem(position: Int) = engineOptions[position]
- override fun getItemId(position: Int) = position.toLong()
-
- override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
- val context = parent.context
- val row = (convertView as? LinearLayout) ?: LinearLayout(context).apply {
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- setPadding(24, 22, 24, 22)
- minimumHeight = (56 * resources.displayMetrics.density).toInt()
-
- addView(ImageView(context).apply {
- id = 1
- val size = (24 * resources.displayMetrics.density).toInt()
- layoutParams = LinearLayout.LayoutParams(size, size)
- })
- addView(TextView(context).apply {
- id = 2
- setTextColor(0xFFFFFFFF.toInt())
- setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
- setPadding(16, 0, 0, 0)
- layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
- })
- }
-
- val icon = row.findViewById(1)
- val title = row.findViewById(2)
- val item = getItem(position)
-
- title.text = item.name
- if (item.iconRes != null) {
- icon.visibility = View.VISIBLE
- icon.setImageResource(item.iconRes)
- icon.imageTintList = null
- } else {
- icon.visibility = View.GONE
- }
-
- return row
- }
- }
-
- listView.adapter = adapter
-
- val dialog = AlertDialog.Builder(this, R.style.DarkDialog)
- .setTitle("Search Engine")
- .setView(listView)
- .setNegativeButton("Cancel", null)
- .create()
-
- listView.setOnItemClickListener { _, _, position, _ ->
- dialog.dismiss()
- val selected = engineOptions[position]
- if (selected.name == "Custom") {
- showCustomSearchEngineDialog()
- } else {
- prefsManager.searchEngine = selected.url
- prefsManager.searchEngineIsCustom = false
- updateSearchEngineValue()
- Toast.makeText(this, "${selected.name} selected", Toast.LENGTH_SHORT).show()
- }
- }
-
- dialog.show()
- }
-
- private fun showCustomSearchEngineDialog() {
- val input = EditText(this).apply {
- val current = prefsManager.searchEngine
- setText(current.takeIf { prefsManager.searchEngineIsCustom } ?: "https://example.com/search?q=%s")
- setTextColor(0xFFFFFFFF.toInt())
- setBackgroundColor(0xFF1A1A1A.toInt())
- setPadding(32, 24, 32, 24)
- hint = "https://example.com/search?q=%s"
- setHintTextColor(0xFF444444.toInt())
- inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
- isSingleLine = true
- }
-
- val wrapper = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(24, 8, 24, 0)
- addView(TextView(context).apply {
- text = "Use %s where the search term should go."
- setTextColor(0xFF9A9A9A.toInt())
- textSize = 13f
- })
- addView(input, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
- topMargin = 16
- })
- }
-
- AlertDialog.Builder(this, R.style.DarkDialog)
- .setTitle("Custom Search Engine")
- .setView(wrapper)
- .setPositiveButton("Save") { _, _ ->
- val value = input.text.toString().trim()
- if (value.isBlank()) {
- Toast.makeText(this, "Enter a valid URL", Toast.LENGTH_SHORT).show()
- return@setPositiveButton
- }
- prefsManager.searchEngine = value
- prefsManager.searchEngineIsCustom = true
- updateSearchEngineValue()
- Toast.makeText(this, "Custom search engine saved", Toast.LENGTH_SHORT).show()
- }
- .setNegativeButton("Cancel", null)
- .show()
- }
-
- private fun openUrl(url: String) {
- startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
- }
-
- override fun finish() {
- super.finish()
- overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
- }
-}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/UpdateManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/UpdateManager.kt
new file mode 100644
index 0000000..aab2795
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/UpdateManager.kt
@@ -0,0 +1,161 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+object UpdateManager {
+
+ // ─── constants ───────────────────────────────────────────────────────────
+
+ private const val PREFS = "update_cache"
+ private const val KEY_VERSION = "latest_version"
+ private const val KEY_NAME = "release_name"
+ private const val KEY_CHANGELOG = "changelog"
+ private const val KEY_URL = "release_url"
+ private const val KEY_DATE = "published_at"
+ private const val KEY_CHECKED = "last_checked"
+ private const val CACHE_TTL = 6 * 60 * 60 * 1000L // 6 hours
+ private const val API_URL =
+ "https://api.github.com/repos/SANDRO00O/ConsoleFlow-mobile/releases/latest"
+
+ // ─── data ────────────────────────────────────────────────────────────────
+
+ data class ReleaseInfo(
+ val latestVersion : String, // e.g. "2.3.0"
+ val releaseName : String, // e.g. "v2.3.0 ConsoleFlow"
+ val changelog : String, // raw GitHub release body (Markdown)
+ val releaseUrl : String, // HTML link on GitHub
+ val publishedAt : String // ISO-8601 date string
+ )
+
+ // ─── private state ───────────────────────────────────────────────────────
+
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build()
+
+ private val main = Handler(Looper.getMainLooper())
+
+ // ─── public API ──────────────────────────────────────────────────────────
+
+ /** Current installed version (from PackageManager). */
+ fun currentVersion(ctx: Context): String =
+ try { ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0.0.0" }
+ catch (_: Exception) { "0.0.0" }
+
+ /** Returns cached release info without any network call. Null if never fetched. */
+ fun getCached(ctx: Context): ReleaseInfo? {
+ val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val ver = p.getString(KEY_VERSION, null) ?: return null
+ return ReleaseInfo(
+ latestVersion = ver,
+ releaseName = p.getString(KEY_NAME, "") ?: "",
+ changelog = p.getString(KEY_CHANGELOG, "") ?: "",
+ releaseUrl = p.getString(KEY_URL, "") ?: "",
+ publishedAt = p.getString(KEY_DATE, "") ?: ""
+ )
+ }
+
+ /**
+ * Fast synchronous cache read — safe on the main thread.
+ * Returns true only when we know for certain a newer version exists.
+ */
+ fun isUpdateAvailable(ctx: Context): Boolean {
+ val cached = getCached(ctx) ?: return false
+ return compare(currentVersion(ctx), cached.latestVersion) < 0
+ }
+
+ /**
+ * Check GitHub releases API.
+ * - Uses cached data when cache is fresh (< 6 h).
+ * - [forceRefresh] bypasses the cache and always hits the network.
+ * - [callback] is always invoked on the **main thread**.
+ * - Network errors fall back to cached data silently.
+ */
+ fun check(
+ ctx : Context,
+ forceRefresh : Boolean = false,
+ callback : (release: ReleaseInfo?, updateAvailable: Boolean) -> Unit
+ ) {
+ val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val age = System.currentTimeMillis() - prefs.getLong(KEY_CHECKED, 0L)
+
+ if (!forceRefresh && age < CACHE_TTL) {
+ val cached = getCached(ctx)
+ val upd = cached != null && compare(currentVersion(ctx), cached.latestVersion) < 0
+ main.post { callback(cached, upd) }
+ return
+ }
+
+ val req = Request.Builder()
+ .url(API_URL)
+ .header("Accept", "application/vnd.github.v3+json")
+ .build()
+
+ http.newCall(req).enqueue(object : Callback {
+
+ override fun onFailure(call: Call, e: IOException) {
+ val cached = getCached(ctx)
+ val upd = cached != null && compare(currentVersion(ctx), cached.latestVersion) < 0
+ main.post { callback(cached, upd) }
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ response.use {
+ if (!it.isSuccessful) { onFailure(call, IOException("HTTP ${it.code}")); return }
+ try {
+ val j = JSONObject(it.body!!.string())
+ val ver = j.getString("tag_name").trimStart('v', 'V')
+ val info = ReleaseInfo(
+ latestVersion = ver,
+ releaseName = j.optString("name", ver),
+ changelog = j.optString("body", ""),
+ releaseUrl = j.optString("html_url", ""),
+ publishedAt = j.optString("published_at", "")
+ )
+ val upd = compare(currentVersion(ctx), ver) < 0
+
+ prefs.edit()
+ .putString(KEY_VERSION, ver)
+ .putString(KEY_NAME, info.releaseName)
+ .putString(KEY_CHANGELOG, info.changelog)
+ .putString(KEY_URL, info.releaseUrl)
+ .putString(KEY_DATE, info.publishedAt)
+ .putLong(KEY_CHECKED, System.currentTimeMillis())
+ .apply()
+
+ main.post { callback(info, upd) }
+ } catch (e: Exception) {
+ onFailure(call, IOException(e))
+ }
+ }
+ }
+ })
+ }
+
+ // ─── version comparison ──────────────────────────────────────────────────
+
+ /**
+ * Semantic version compare (major.minor.patch).
+ * Returns < 0 if a < b, 0 if equal, > 0 if a > b.
+ */
+ fun compare(a: String, b: String): Int {
+ val pa = a.split(".").map { it.trim().toIntOrNull() ?: 0 }
+ val pb = b.split(".").map { it.trim().toIntOrNull() ?: 0 }
+ for (i in 0 until maxOf(pa.size, pb.size)) {
+ val d = (pa.getOrElse(i) { 0 }) - (pb.getOrElse(i) { 0 })
+ if (d != 0) return d
+ }
+ return 0
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/WebViewSettingsHelper.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/WebViewSettingsHelper.kt
new file mode 100644
index 0000000..3de8209
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/WebViewSettingsHelper.kt
@@ -0,0 +1,30 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+import android.webkit.WebSettings
+import android.webkit.WebView
+
+object WebViewSettingsHelper {
+ fun getUserAgentString(context: Context, desktopMode: Boolean): String {
+ val defaultUA = WebSettings.getDefaultUserAgent(context)
+ if (!desktopMode) return defaultUA
+
+ return try {
+ val chromeVersion = Regex("Chrome/([0-9.]+)").find(defaultUA)?.value
+ ?: "Chrome/124.0.0.0"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) $chromeVersion Safari/537.36"
+ } catch (_: Exception) {
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
+ }
+ }
+
+ fun applyUserAgentToWebView(context: Context, webView: WebView, desktopMode: Boolean) {
+ webView.settings.apply {
+ userAgentString = getUserAgentString(context, desktopMode)
+ useWideViewPort = desktopMode
+ loadWithOverviewMode = desktopMode
+ }
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/cache/BrowserCacheFiles.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/cache/BrowserCacheFiles.kt
new file mode 100644
index 0000000..82ef1c5
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/cache/BrowserCacheFiles.kt
@@ -0,0 +1,20 @@
+package space.karrarnazim.ConsoleFlow
+
+import java.io.File
+
+object BrowserCacheFiles {
+ fun tabThumbnailFile(cacheDir: File, tabId: Int): File =
+ File(cacheDir, "thumb_$tabId.webp")
+
+ fun homePreviewFile(cacheDir: File, width: Int, height: Int): File =
+ File(cacheDir, "home_preview_${width}x${height}.webp")
+
+ fun homePreviewSigFile(cacheDir: File, width: Int, height: Int): File =
+ File(cacheDir, "home_preview_${width}x${height}.sig")
+
+ fun hasCachedTabThumbnail(cacheDir: File, tabId: Int): Boolean =
+ tabThumbnailFile(cacheDir, tabId).exists()
+
+ fun homePreviewCacheKey(width: Int, height: Int): String =
+ "${width}x${height}"
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/cache/ThumbnailManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/cache/ThumbnailManager.kt
new file mode 100644
index 0000000..ab7498e
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/cache/ThumbnailManager.kt
@@ -0,0 +1,10 @@
+package space.karrarnazim.ConsoleFlow
+
+import java.io.File
+
+object ThumbnailManager {
+ fun tabThumbnailFile(cacheDir: File, tabId: Int): File = BrowserCacheFiles.tabThumbnailFile(cacheDir, tabId)
+ fun homePreviewFile(cacheDir: File, width: Int, height: Int): File = BrowserCacheFiles.homePreviewFile(cacheDir, width, height)
+ fun homePreviewSigFile(cacheDir: File, width: Int, height: Int): File = BrowserCacheFiles.homePreviewSigFile(cacheDir, width, height)
+ fun hasCachedTabThumbnail(cacheDir: File, tabId: Int): Boolean = BrowserCacheFiles.hasCachedTabThumbnail(cacheDir, tabId)
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/console/ConsoleScripts.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/console/ConsoleScripts.kt
new file mode 100644
index 0000000..e6556fa
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/console/ConsoleScripts.kt
@@ -0,0 +1,52 @@
+package space.karrarnazim.ConsoleFlow
+
+object ConsoleScripts {
+ fun initScript(): String =
+ "(function(){" +
+ "window.__cfConsoleEnabled=true;" +
+ "var el=document.getElementById('eruda');" +
+ "if(window.__erudaInited){" +
+ "try{if(window.eruda&&eruda.show)eruda.show();}catch(e){}" +
+ "if(el)el.style.display='';" +
+ "return;" +
+ "}" +
+ "if(typeof eruda!=='undefined'){" +
+ "try{eruda.init();window.__erudaInited=true;window.__cfConsoleEnabled=true;" +
+ "if(el)el.style.display='';}catch(e){}" +
+ "return;" +
+ "}" +
+ "var x=new XMLHttpRequest();" +
+ "x.open('GET','https://eruda.local/eruda.js',true);" +
+ "x.onload=function(){" +
+ "if(window.__erudaInited)return;" +
+ "try{eval(x.responseText);eruda.init();window.__erudaInited=true;" +
+ "window.__cfConsoleEnabled=true;if(el)el.style.display='';}catch(e){}" +
+ "};" +
+ "x.send();" +
+ "})()"
+
+ fun disableScript(): String =
+ "(function(){" +
+ "window.__cfConsoleEnabled=false;" +
+ "try{if(window.eruda&&eruda.hide)eruda.hide();}catch(e){}" +
+ "var el=document.getElementById('eruda');" +
+ "if(el){el.style.display='none';el.classList.add('__cf_console_hidden');}" +
+ "})()"
+
+ fun touchHookScript(): String =
+ "(function(){" +
+ "if(window.__erudaTouchHooked)return;" +
+ "window.__erudaTouchHooked=true;" +
+ "function getErudaEl(){return document.getElementById('eruda');}" +
+ "function consoleEnabled(){return window.__cfConsoleEnabled!==false;}" +
+ "document.addEventListener('touchstart',function(e){" +
+ "if(!consoleEnabled())return;" +
+ "var el=getErudaEl();" +
+ "if(el&&el.contains(e.target)){try{Android.setSwipeRefresh(false);}catch(ex){}}" +
+ "},{capture:true,passive:true});" +
+ "document.addEventListener('touchend',function(){" +
+ "if(!consoleEnabled())return;" +
+ "try{Android.setSwipeRefresh(true);}catch(ex){}" +
+ "},{capture:true,passive:true});" +
+ "})()"
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/console/ErudaManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/console/ErudaManager.kt
new file mode 100644
index 0000000..70edb02
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/console/ErudaManager.kt
@@ -0,0 +1,7 @@
+package space.karrarnazim.ConsoleFlow
+
+object ErudaManager {
+ fun initScript(): String = ConsoleScripts.initScript()
+ fun disableScript(): String = ConsoleScripts.disableScript()
+ fun touchHookScript(): String = ConsoleScripts.touchHookScript()
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/console/UserScriptsManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/console/UserScriptsManager.kt
new file mode 100644
index 0000000..4e60a8c
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/console/UserScriptsManager.kt
@@ -0,0 +1,18 @@
+package space.karrarnazim.ConsoleFlow
+
+object UserScriptsManager {
+ fun buildInjectedScripts(consoleEnabled: Boolean, customJs: String): String {
+ val erudaTags = if (consoleEnabled) {
+ "" +
+ ""
+ } else ""
+
+ val customJsTag = customJs.takeIf { it.isNotEmpty() }
+ ?.let { "" } ?: ""
+
+ return erudaTags + customJsTag
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadItem.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadItem.kt
new file mode 100644
index 0000000..ae242b6
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadItem.kt
@@ -0,0 +1,18 @@
+package space.karrarnazim.ConsoleFlow
+
+enum class DownloadState { QUEUED, RUNNING, COMPLETED, FAILED, CANCELLED }
+
+data class DownloadItem(
+ val id: Int,
+ val url: String,
+ val fileName: String,
+ val mimeType: String,
+ var totalBytes: Long = -1L,
+ var downloadedBytes: Long = 0L,
+ var state: DownloadState = DownloadState.QUEUED,
+ var filePath: String? = null,
+ var speedBytesPerSec: Long = 0L,
+ var etaSeconds: Long = -1L,
+ var error: String? = null,
+ val startTime: Long = System.currentTimeMillis()
+)
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadService.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadService.kt
new file mode 100644
index 0000000..210aac8
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadService.kt
@@ -0,0 +1,442 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.app.*
+import android.content.*
+import android.media.MediaScannerConnection
+import android.net.Uri
+import android.os.*
+import android.provider.MediaStore
+import android.webkit.URLUtil
+import androidx.core.app.NotificationCompat
+import okhttp3.*
+import java.io.*
+import java.util.concurrent.*
+import java.util.concurrent.atomic.AtomicInteger
+
+class DownloadService : Service() {
+
+ companion object {
+ const val ACTION_START = "cf.dl.start"
+ const val ACTION_CANCEL = "cf.dl.cancel"
+
+ const val EXTRA_URL = "url"
+ const val EXTRA_FILENAME = "fname"
+ const val EXTRA_MIME = "mime"
+ const val EXTRA_UA = "ua"
+ const val EXTRA_COOKIES = "cookies"
+ const val EXTRA_DL_ID = "dlid"
+
+ const val CHANNEL_ID = "cf_downloads"
+ private const val NOTIF_FOREGROUND = 999
+ private const val NOTIF_BASE = 1000
+ }
+
+ private val executor = Executors.newFixedThreadPool(3)
+ private val activeCount = AtomicInteger(0)
+ private val notifIdGen = AtomicInteger(NOTIF_BASE)
+
+ private val notifManager: NotificationManager by lazy {
+ getSystemService(NotificationManager::class.java)!!
+ }
+
+ private val okClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(0, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Lifecycle
+ // ─────────────────────────────────────────────────────────────────────────
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ startForeground(NOTIF_FOREGROUND, buildSummaryNotif("Starting…"))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> handleStart(intent)
+ ACTION_CANCEL -> handleCancel(intent)
+ }
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Command handlers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun handleStart(intent: Intent) {
+ val url = intent.getStringExtra(EXTRA_URL) ?: return
+ val fileName = intent.getStringExtra(EXTRA_FILENAME)
+ ?: URLUtil.guessFileName(url, null, null)
+ val mime = intent.getStringExtra(EXTRA_MIME) ?: "application/octet-stream"
+ val ua = intent.getStringExtra(EXTRA_UA) ?: "Mozilla/5.0"
+ val cookies = intent.getStringExtra(EXTRA_COOKIES)
+
+ val id = DownloadTracker.nextId()
+ val notifId = notifIdGen.getAndIncrement()
+
+ DownloadTracker.add(DownloadItem(id = id, url = url, fileName = fileName, mimeType = mime))
+
+ val count = activeCount.incrementAndGet()
+ refreshSummaryNotif(count)
+
+ executor.execute { doDownload(id, url, fileName, mime, ua, cookies, notifId) }
+ }
+
+ private fun handleCancel(intent: Intent) {
+ val id = intent.getIntExtra(EXTRA_DL_ID, -1)
+ if (id != -1) DownloadTracker.update(id) { state = DownloadState.CANCELLED }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Download loop
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun doDownload(
+ id: Int,
+ url: String,
+ fileName: String,
+ mime: String,
+ ua: String,
+ cookies: String?,
+ notifId: Int
+ ) {
+ DownloadTracker.update(id) { state = DownloadState.RUNNING }
+
+ try {
+ val request = Request.Builder()
+ .url(url)
+ .header("User-Agent", ua)
+ .apply { if (!cookies.isNullOrEmpty()) header("Cookie", cookies) }
+ .build()
+
+ val response = okClient.newCall(request).execute()
+ if (!response.isSuccessful) {
+ fail(id, notifId, fileName, "HTTP ${response.code}")
+ return
+ }
+
+ val totalBytes = response.body?.contentLength() ?: -1L
+ DownloadTracker.update(id) { this.totalBytes = totalBytes }
+
+ val (outputStream, filePath) = createOutputFile(fileName, mime)
+
+ try {
+ val body = response.body ?: throw IOException("Empty body")
+ val buf = ByteArray(16 * 1024)
+ val input = body.byteStream()
+ var done = 0L
+
+ // Time-based sliding window — keeps samples from the last WINDOW_MS
+ val WINDOW_MS = 5_000L // look back 5 seconds for raw speed
+ val UPDATE_MS = 800L // update UI / notification every 800 ms
+
+ // EMA constants: low alpha = very stable display, slow to react
+ // alpha=0.20 for speed: ~5 updates (~4s) to fully reflect a change
+ // alpha=0.10 for ETA: ~10 updates (~8s) — barely flickers
+ val SPEED_ALPHA = 0.20
+ val ETA_ALPHA = 0.10
+
+ val winTime = ArrayDeque()
+ val winBytes = ArrayDeque()
+ var smoothedSpeed = 0L
+ var smoothedEta = -1L
+ var lastUpdateMs = 0L
+
+ loop@ while (true) {
+ if (DownloadTracker.getById(id)?.state == DownloadState.CANCELLED) {
+ input.close(); outputStream.close()
+ deletePartial(filePath)
+ notifManager.cancel(notifId)
+ return
+ }
+
+ val n = input.read(buf)
+ if (n == -1) break@loop
+
+ outputStream.write(buf, 0, n)
+ done += n
+
+ val now = System.currentTimeMillis()
+
+ // ── Only compute + update every UPDATE_MS ─────────────────
+ if (now - lastUpdateMs < UPDATE_MS) continue@loop
+ lastUpdateMs = now
+
+ // Time-based window: discard samples older than WINDOW_MS
+ winTime.addLast(now); winBytes.addLast(done)
+ while (winTime.size > 1 && (now - winTime.first()) > WINDOW_MS) {
+ winTime.removeFirst(); winBytes.removeFirst()
+ }
+
+ // Raw speed over the window
+ val rawSpeed: Long = if (winTime.size >= 2) {
+ val dtSec = (winTime.last() - winTime.first()) / 1000.0
+ val db = winBytes.last() - winBytes.first()
+ if (dtSec > 0) (db / dtSec).toLong() else smoothedSpeed
+ } else smoothedSpeed
+
+ // EMA on speed — damps short spikes completely
+ smoothedSpeed = if (smoothedSpeed == 0L) rawSpeed
+ else ((SPEED_ALPHA * rawSpeed) + ((1 - SPEED_ALPHA) * smoothedSpeed)).toLong()
+
+ // Raw ETA from smoothed speed, then EMA on ETA
+ val rawEta = if (smoothedSpeed > 0 && totalBytes > 0)
+ (totalBytes - done) / smoothedSpeed else -1L
+ smoothedEta = when {
+ rawEta < 0 -> -1L
+ smoothedEta <= 0 -> rawEta
+ else -> ((ETA_ALPHA * rawEta) + ((1 - ETA_ALPHA) * smoothedEta)).toLong()
+ }
+
+ DownloadTracker.update(id) {
+ downloadedBytes = done
+ speedBytesPerSec = smoothedSpeed
+ etaSeconds = smoothedEta
+ }
+
+ val pct = if (totalBytes > 0) ((done * 100) / totalBytes).toInt() else -1
+ updateProgressNotif(notifId, fileName, pct, done, totalBytes, smoothedSpeed, id)
+ }
+
+ outputStream.close(); input.close()
+ finalizeFile(filePath, fileName, mime)
+
+ DownloadTracker.update(id) {
+ state = DownloadState.COMPLETED
+ downloadedBytes = done
+ this.filePath = filePath
+ }
+ notifManager.cancel(notifId)
+ showCompletedNotif(notifId, fileName, filePath, mime)
+
+ } catch (e: IOException) {
+ try { outputStream.close() } catch (_: Exception) {}
+ if (DownloadTracker.getById(id)?.state != DownloadState.CANCELLED) {
+ deletePartial(filePath)
+ fail(id, notifId, fileName, e.message ?: "IO error")
+ } else {
+ notifManager.cancel(notifId)
+ }
+ }
+
+ } catch (e: Exception) {
+ if (DownloadTracker.getById(id)?.state != DownloadState.CANCELLED)
+ fail(id, notifId, fileName, e.message ?: "Network error")
+ else
+ notifManager.cancel(notifId)
+ } finally {
+ val remaining = activeCount.decrementAndGet()
+ refreshSummaryNotif(remaining)
+ if (remaining == 0) stopWhenIdle()
+ }
+ }
+
+ private fun fail(id: Int, notifId: Int, fileName: String, msg: String) {
+ DownloadTracker.update(id) { state = DownloadState.FAILED; error = msg }
+ notifManager.cancel(notifId)
+ notifManager.notify(notifId, NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setContentTitle("Download failed")
+ .setContentText(fileName)
+ .setSubText(msg)
+ .setAutoCancel(true)
+ .build())
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // File helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun createOutputFile(fileName: String, mime: String): Pair {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val cv = ContentValues().apply {
+ put(MediaStore.Downloads.DISPLAY_NAME, fileName)
+ put(MediaStore.Downloads.MIME_TYPE, mime)
+ put(MediaStore.Downloads.IS_PENDING, 1)
+ }
+ val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv)
+ ?: throw IOException("MediaStore insert failed")
+ Pair(contentResolver.openOutputStream(uri)!!, uri.toString())
+ } else {
+ @Suppress("DEPRECATION")
+ val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ dir.mkdirs()
+ var file = File(dir, fileName)
+ if (file.exists()) {
+ val base = fileName.substringBeforeLast(".")
+ val ext = fileName.substringAfterLast(".", "")
+ var n = 1
+ do {
+ file = if (ext.isNotEmpty()) File(dir, "$base ($n).$ext")
+ else File(dir, "$fileName ($n)")
+ n++
+ } while (file.exists())
+ }
+ Pair(FileOutputStream(file), file.absolutePath)
+ }
+ }
+
+ private fun finalizeFile(path: String, fileName: String, mime: String) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ contentResolver.update(Uri.parse(path),
+ ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) },
+ null, null)
+ } else {
+ try { MediaScannerConnection.scanFile(this, arrayOf(path), arrayOf(mime), null) }
+ catch (_: Exception) {}
+ }
+ }
+
+ private fun deletePartial(path: String) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ contentResolver.delete(Uri.parse(path), null, null)
+ else
+ File(path).delete()
+ } catch (_: Exception) {}
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Notifications
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ notifManager.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Downloads", NotificationManager.IMPORTANCE_LOW)
+ .apply {
+ description = "ConsoleFlow download progress"
+ setShowBadge(false)
+ }
+ )
+ }
+ }
+
+ /** Tap-to-open downloads page intent — reused in several notifications */
+ private fun openDownloadsPagePi(): PendingIntent = PendingIntent.getActivity(
+ this, 0,
+ Intent(this, DownloadsActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ /** Updates (or creates) the persistent foreground notification with download count. */
+ private fun refreshSummaryNotif(activeNow: Int) {
+ val text = when {
+ activeNow > 1 -> "$activeNow downloads in progress"
+ activeNow == 1 -> "1 download in progress"
+ else -> "Downloads complete"
+ }
+ notifManager.notify(NOTIF_FOREGROUND, buildSummaryNotif(text))
+ }
+
+ private fun buildSummaryNotif(text: String): Notification =
+ NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle("ConsoleFlow Downloads")
+ .setContentText(text)
+ .setContentIntent(openDownloadsPagePi())
+ .setOngoing(true)
+ .build()
+
+ private fun updateProgressNotif(
+ notifId: Int,
+ fileName: String,
+ pct: Int,
+ done: Long,
+ total: Long,
+ speed: Long,
+ dlId: Int
+ ) {
+ val cancelPi = PendingIntent.getService(
+ this, dlId,
+ Intent(this, DownloadService::class.java).apply {
+ action = ACTION_CANCEL
+ putExtra(EXTRA_DL_ID, dlId)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val statusText = buildString {
+ append(formatBytes(done))
+ if (total > 0) append(" / ${formatBytes(total)}")
+ if (speed > 0) {
+ append(" • ${formatSpeed(speed)}")
+ val eta = DownloadTracker.getById(dlId)?.etaSeconds ?: -1
+ if (eta > 0) append(" • ${formatEta(eta)}")
+ }
+ }
+
+ notifManager.notify(notifId, NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle(fileName)
+ .setContentText(statusText)
+ .setProgress(100, if (pct >= 0) pct else 0, pct < 0)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(openDownloadsPagePi())
+ .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPi)
+ .build())
+ }
+
+ private fun showCompletedNotif(notifId: Int, fileName: String, filePath: String, mime: String) {
+ val tapPi: PendingIntent? = if (filePath.startsWith("content://")) {
+ runCatching {
+ PendingIntent.getActivity(
+ this, notifId,
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(Uri.parse(filePath), mime)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }.getOrNull()
+ } else {
+ openDownloadsPagePi()
+ }
+
+ notifManager.notify(notifId, NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setContentTitle("Download complete")
+ .setContentText(fileName)
+ .setAutoCancel(true)
+ .apply { if (tapPi != null) setContentIntent(tapPi) }
+ .build())
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Self-management
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun stopWhenIdle() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ else
+ @Suppress("DEPRECATION") stopForeground(true)
+ stopSelf()
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Formatters
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun formatBytes(b: Long): String = when {
+ b < 1_024L -> "$b B"
+ b < 1_048_576L -> "${"%.1f".format(b / 1_024.0)} KB"
+ b < 1_073_741_824L -> "${"%.1f".format(b / 1_048_576.0)} MB"
+ else -> "${"%.2f".format(b / 1_073_741_824.0)} GB"
+ }
+ private fun formatSpeed(bps: Long) = "${formatBytes(bps)}/s"
+ private fun formatEta(sec: Long): String = when {
+ sec < 60 -> "${sec}s"
+ sec < 3600 -> "${sec / 60}m ${sec % 60}s"
+ else -> "${sec / 3600}h ${(sec % 3600) / 60}m"
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadTracker.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadTracker.kt
new file mode 100644
index 0000000..1bb16d3
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/download/DownloadTracker.kt
@@ -0,0 +1,57 @@
+package space.karrarnazim.ConsoleFlow
+
+import androidx.lifecycle.MutableLiveData
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Singleton state for all downloads.
+ *
+ * KEY FIX: We maintain _list as the private source of truth.
+ * LiveData.getValue() is NOT safe from background threads — it can return
+ * stale data between postValue() calls. Reading from _list (under lock)
+ * guarantees we always see the latest state.
+ */
+object DownloadTracker {
+
+ private val _list = mutableListOf()
+ val downloads: MutableLiveData> = MutableLiveData(emptyList())
+
+ private val idGen = AtomicInteger(1)
+ private val lock = Any()
+
+ fun nextId(): Int = idGen.getAndIncrement()
+
+ fun add(item: DownloadItem) = synchronized(lock) {
+ _list.add(item)
+ downloads.postValue(_list.toList())
+ }
+
+ fun update(id: Int, block: DownloadItem.() -> Unit) = synchronized(lock) {
+ val idx = _list.indexOfFirst { it.id == id }
+ if (idx >= 0) {
+ _list[idx] = _list[idx].copy().also(block)
+ downloads.postValue(_list.toList())
+ }
+ }
+
+ fun remove(id: Int) = synchronized(lock) {
+ _list.removeAll { it.id == id }
+ downloads.postValue(_list.toList())
+ }
+
+ fun clearFinished() = synchronized(lock) {
+ _list.removeAll {
+ it.state != DownloadState.RUNNING && it.state != DownloadState.QUEUED
+ }
+ downloads.postValue(_list.toList())
+ }
+
+ // Safe to call from any thread
+ fun getById(id: Int): DownloadItem? = synchronized(lock) {
+ _list.find { it.id == id }
+ }
+
+ fun hasActive(): Boolean = synchronized(lock) {
+ _list.any { it.state == DownloadState.RUNNING || it.state == DownloadState.QUEUED }
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/BookmarkRepository.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/BookmarkRepository.kt
new file mode 100644
index 0000000..720a807
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/BookmarkRepository.kt
@@ -0,0 +1,10 @@
+package space.karrarnazim.ConsoleFlow
+
+class BookmarkRepository(private val prefsManager: PrefsManager) {
+ fun toggleBookmark(title: String, url: String): Boolean =
+ prefsManager.toggleBookmark(title, url)
+
+ fun isBookmarked(url: String): Boolean = prefsManager.isBookmarked(url)
+
+ fun getBookmarks(): List> = prefsManager.getBookmarks()
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/BrowserPersistence.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/BrowserPersistence.kt
new file mode 100644
index 0000000..42556d9
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/BrowserPersistence.kt
@@ -0,0 +1,106 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class RestoredBrowserTabs(
+ val groups: MutableList,
+ val activeGroupId: Int,
+ val activeTabId: Int,
+ val nextTabId: Int,
+ val nextGroupId: Int
+)
+
+object BrowserPersistence {
+ fun save(
+ context: Context,
+ snapshot: List,
+ activeGroupId: Int,
+ activeTabId: Int,
+ nextTabId: Int,
+ nextGroupId: Int
+ ) {
+ try {
+ val groupsArray = JSONArray()
+ for (group in snapshot) {
+ val groupObj = JSONObject()
+ groupObj.put("id", group.id)
+ groupObj.put("name", group.name)
+
+ val tabsArray = JSONArray()
+ for (tab in group.tabs) {
+ val tabObj = JSONObject()
+ tabObj.put("id", tab.id)
+ tabObj.put("title", tab.title)
+ tabObj.put("url", tab.url)
+ tabObj.put("hasThumb", tab.hasThumbnail)
+ tabObj.put("thumbUrl", tab.thumbnailUrl)
+ tabsArray.put(tabObj)
+ }
+ groupObj.put("tabs", tabsArray)
+ groupsArray.put(groupObj)
+ }
+
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .putString("SAVED_GROUPS", groupsArray.toString())
+ .putInt("ACTIVE_GROUP", activeGroupId)
+ .putInt("ACTIVE_TAB", activeTabId)
+ .putInt("NEXT_TAB_ID", nextTabId)
+ .putInt("NEXT_GROUP_ID", nextGroupId)
+ .apply()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ fun restore(context: Context): RestoredBrowserTabs? {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val savedJson = prefs.getString("SAVED_GROUPS", null) ?: return null
+
+ return try {
+ val groups = mutableListOf()
+ val groupsArray = JSONArray(savedJson)
+ if (groupsArray.length() == 0) return null
+
+ for (i in 0 until groupsArray.length()) {
+ val gObj = groupsArray.getJSONObject(i)
+ val group = TabGroup(gObj.getInt("id"), gObj.getString("name"))
+
+ val tabsArray = gObj.getJSONArray("tabs")
+ for (j in 0 until tabsArray.length()) {
+ val tObj = tabsArray.getJSONObject(j)
+ val rawUrl = tObj.getString("url")
+ val tabId = tObj.getInt("id")
+ group.tabs.add(
+ TabState(
+ id = tabId,
+ title = tObj.getString("title"),
+ url = if (isHomeUrl(rawUrl)) HOME_URL_CONST else rawUrl,
+ hasThumbnail = tObj.optBoolean("hasThumb", false) ||
+ ThumbnailManager.hasCachedTabThumbnail(context.cacheDir, tabId),
+ thumbnailUrl = tObj.optString("thumbUrl", rawUrl)
+ )
+ )
+ }
+ groups.add(group)
+ }
+
+ val activeGroupId = prefs.getInt("ACTIVE_GROUP", groups.first().id)
+ val activeGroupTabs = groups.firstOrNull { it.id == activeGroupId }?.tabs
+ RestoredBrowserTabs(
+ groups = groups,
+ activeGroupId = activeGroupId,
+ activeTabId = prefs.getInt(
+ "ACTIVE_TAB",
+ activeGroupTabs?.firstOrNull()?.id ?: groups.firstOrNull()?.tabs?.firstOrNull()?.id ?: 0
+ ),
+ nextTabId = prefs.getInt("NEXT_TAB_ID", 100),
+ nextGroupId = prefs.getInt("NEXT_GROUP_ID", 100)
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/HistoryRepository.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/HistoryRepository.kt
new file mode 100644
index 0000000..e06d423
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/HistoryRepository.kt
@@ -0,0 +1,9 @@
+package space.karrarnazim.ConsoleFlow
+
+class HistoryRepository(private val prefsManager: PrefsManager) {
+ fun addHistory(title: String, url: String) = prefsManager.addHistory(title, url)
+
+ fun clearHistory() = prefsManager.clearHistory()
+
+ fun getHistory(): List> = prefsManager.getHistory()
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/SettingsRepository.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/SettingsRepository.kt
new file mode 100644
index 0000000..007640b
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/storage/SettingsRepository.kt
@@ -0,0 +1,119 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+import android.content.SharedPreferences
+import org.json.JSONArray
+import org.json.JSONObject
+
+open class SettingsRepository(context: Context) {
+ private val prefs: SharedPreferences = context.getSharedPreferences("ConsoleFlowPrefs", Context.MODE_PRIVATE)
+
+ var desktopMode: Boolean
+ get() = prefs.getBoolean("desktop_mode", false)
+ set(value) = prefs.edit().putBoolean("desktop_mode", value).apply()
+
+ var searchEngine: String
+ get() = prefs.getString("search_engine", "https://www.google.com/search?q=")!!
+ set(value) = prefs.edit().putString("search_engine", value).apply()
+
+ var searchEngineIsCustom: Boolean
+ get() = prefs.getBoolean("search_engine_is_custom", false)
+ set(value) = prefs.edit().putBoolean("search_engine_is_custom", value).apply()
+
+ var customJs: String
+ get() = prefs.getString("custom_js", "")!!
+ set(value) = prefs.edit().putString("custom_js", value).apply()
+
+ var consoleEnabled: Boolean
+ get() = prefs.getBoolean("console_enabled", false)
+ set(value) = prefs.edit().putBoolean("console_enabled", value).apply()
+
+ // ✅ الدالة المضافة حديثًا لحل خطأ "Unresolved reference: getBoolean"
+ fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ return prefs.getBoolean(key, defaultValue)
+ }
+
+ fun addHistory(title: String, url: String) {
+ if (url == "about:blank" || url == "error://page" || url.startsWith("error://")) return
+ val historyArray = getList("history")
+ val newItem = JSONObject().apply {
+ put("title", title)
+ put("url", url)
+ }
+ for (i in 0 until historyArray.length()) {
+ val old = historyArray.optJSONObject(i) ?: continue
+ if (old.optString("url") == url) {
+ historyArray.remove(i)
+ break
+ }
+ }
+ historyArray.put(newItem)
+ while (historyArray.length() > 100) historyArray.remove(0)
+ prefs.edit().putString("history", historyArray.toString()).apply()
+ }
+
+ fun clearHistory() = prefs.edit().remove("history").apply()
+
+ fun toggleBookmark(title: String, url: String): Boolean {
+ val bookmarks = getList("bookmarks")
+ var exists = false
+ var indexToRemove = -1
+
+ for (i in 0 until bookmarks.length()) {
+ val old = bookmarks.optJSONObject(i) ?: continue
+ if (old.optString("url") == url) {
+ exists = true
+ indexToRemove = i
+ break
+ }
+ }
+
+ if (exists) {
+ bookmarks.remove(indexToRemove)
+ } else {
+ bookmarks.put(JSONObject().apply {
+ put("title", title)
+ put("url", url)
+ })
+ }
+ prefs.edit().putString("bookmarks", bookmarks.toString()).apply()
+ return !exists
+ }
+
+ fun isBookmarked(url: String): Boolean {
+ val bookmarks = getList("bookmarks")
+ for (i in 0 until bookmarks.length()) {
+ if (bookmarks.optJSONObject(i)?.optString("url") == url) return true
+ }
+ return false
+ }
+
+ fun getBookmarks(): List> {
+ val bookmarks = getList("bookmarks")
+ val list = mutableListOf>()
+ for (i in 0 until bookmarks.length()) {
+ try {
+ val obj = bookmarks.optJSONObject(i) ?: JSONObject(bookmarks.getString(i))
+ list.add(Pair(obj.getString("title"), obj.getString("url")))
+ } catch (e: Exception) { }
+ }
+ return list
+ }
+
+ fun getHistory(): List> {
+ val history = getList("history")
+ val list = mutableListOf>()
+ for (i in history.length() - 1 downTo 0) {
+ try {
+ val obj = history.optJSONObject(i) ?: JSONObject(history.getString(i))
+ list.add(Pair(obj.getString("title"), obj.getString("url")))
+ } catch (e: Exception) { }
+ }
+ return list
+ }
+
+ private fun getList(key: String): JSONArray {
+ val jsonStr = prefs.getString(key, "[]")
+ return try { JSONArray(jsonStr) } catch (e: Exception) { JSONArray() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/BrowserSessionManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/BrowserSessionManager.kt
new file mode 100644
index 0000000..be36f35
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/BrowserSessionManager.kt
@@ -0,0 +1,62 @@
+
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+
+class BrowserSessionManager {
+ val tabGroups: MutableList = mutableListOf()
+ var activeGroupId: Int = 0
+ var activeTabId: Int = 0
+ var nextTabId: Int = 1
+ var nextGroupId: Int = 1
+
+ val currentGroup: TabGroup?
+ get() = tabGroups.find { it.id == activeGroupId }
+
+ val currentTab: TabState?
+ get() = currentGroup?.tabs?.find { it.id == activeTabId }
+
+ fun saveToStorage(context: Context) {
+ BrowserPersistence.save(
+ context = context,
+ snapshot = tabGroups.map { it.copy(tabs = it.tabs.toMutableList()) },
+ activeGroupId = activeGroupId,
+ activeTabId = activeTabId,
+ nextTabId = nextTabId,
+ nextGroupId = nextGroupId
+ )
+ }
+
+ fun restoreFromStorage(context: Context): Boolean {
+ val restored = BrowserPersistence.restore(context) ?: return false
+ tabGroups.clear()
+ tabGroups.addAll(restored.groups)
+ activeGroupId = restored.activeGroupId
+ activeTabId = restored.activeTabId
+ nextTabId = restored.nextTabId
+ nextGroupId = restored.nextGroupId
+ return tabGroups.isNotEmpty()
+ }
+
+ fun createNewGroup(name: String): TabGroup {
+ val group = TabGroup(nextGroupId++, name)
+ tabGroups.add(group)
+ activeGroupId = group.id
+ return group
+ }
+
+ fun createNewTab(url: String, title: String = "New Tab"): TabState {
+ val tab = TabState(id = nextTabId++, title = title, url = url)
+ currentGroup?.tabs?.add(tab)
+ activeTabId = tab.id
+ return tab
+ }
+
+ fun sanitizeActiveTabSelection(preferredTabId: Int? = null) {
+ val group = currentGroup ?: return
+ val preferred = preferredTabId?.let { wanted -> group.tabs.firstOrNull { it.id == wanted } }
+ val validActive = group.tabs.firstOrNull { it.id == activeTabId }
+ val fallback = preferred ?: validActive ?: group.tabs.firstOrNull()
+ activeTabId = fallback?.id ?: 0
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabGroup.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabGroup.kt
new file mode 100644
index 0000000..295514a
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabGroup.kt
@@ -0,0 +1,10 @@
+package space.karrarnazim.ConsoleFlow
+
+import java.io.Serializable
+
+
+data class TabGroup(
+ val id: Int,
+ var name: String,
+ val tabs: MutableList = mutableListOf()
+) : Serializable
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabManager.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabManager.kt
new file mode 100644
index 0000000..fb10f88
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabManager.kt
@@ -0,0 +1,12 @@
+package space.karrarnazim.ConsoleFlow
+
+class TabManager(private val sessionManager: BrowserSessionManager) {
+ fun createGroup(name: String): TabGroup = sessionManager.createNewGroup(name)
+ fun createTab(url: String, title: String = "New Tab"): TabState = sessionManager.createNewTab(url, title)
+ fun currentGroup(): TabGroup? = sessionManager.currentGroup
+ fun currentTab(): TabState? = sessionManager.currentTab
+ fun activate(tabId: Int) {
+ sessionManager.activeTabId = tabId
+ sessionManager.sanitizeActiveTabSelection(tabId)
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabState.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabState.kt
new file mode 100644
index 0000000..2ae3ad1
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/tabs/TabState.kt
@@ -0,0 +1,15 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.graphics.Bitmap
+import java.io.Serializable
+
+data class TabState(
+ val id: Int,
+ var title: String = "New Tab",
+ var url: String = "",
+ var hasThumbnail: Boolean = false,
+ var thumbnailUrl: String? = null
+) : Serializable {
+ @Transient var ramThumbnail: Bitmap? = null
+ @Transient var faviconBitmap: Bitmap? = null
+}
\ No newline at end of file
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/BrowserScreenController.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/BrowserScreenController.kt
new file mode 100644
index 0000000..b3fcd11
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/BrowserScreenController.kt
@@ -0,0 +1,27 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.app.Activity
+
+class BrowserScreenController(
+ private val activity: Activity,
+ private val sessionManager: BrowserSessionManager,
+ private val onUiChanged: () -> Unit = {}
+) {
+ fun createGroup(name: String): TabGroup {
+ val group = sessionManager.createNewGroup(name)
+ onUiChanged()
+ return group
+ }
+
+ fun openTab(url: String, title: String = "New Tab"): TabState {
+ val tab = sessionManager.createNewTab(url, title)
+ onUiChanged()
+ return tab
+ }
+
+ fun activateTab(tabId: Int) {
+ sessionManager.activeTabId = tabId
+ sessionManager.sanitizeActiveTabSelection(tabId)
+ onUiChanged()
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/DownloadsActivity.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/DownloadsActivity.kt
new file mode 100644
index 0000000..d424b10
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/DownloadsActivity.kt
@@ -0,0 +1,419 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.ContentUris
+import android.content.Intent
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.net.Uri
+import android.os.*
+import android.provider.MediaStore
+import android.view.Gravity
+import android.view.View
+import android.widget.*
+import androidx.appcompat.app.AppCompatActivity
+
+class DownloadsActivity : AppCompatActivity() {
+
+ // ── Colour palette — black base like main UI ──────────────────────────
+ private val cBg = Color.BLACK
+ private val cSurface = Color.parseColor("#111111")
+ private val cSurfaceHigh = Color.parseColor("#1C1C1C")
+ private val cOnBg = Color.parseColor("#E6E1E5")
+ private val cOnSurfaceVar = Color.parseColor("#AAAAAA")
+ private val cPrimary = Color.parseColor("#6EA8DC")
+ private val cPrimaryDim = Color.parseColor("#0D2033")
+ private val cError = Color.parseColor("#F2B8B5")
+ private val cSuccess = Color.parseColor("#5DB075")
+ private val cOutline = Color.parseColor("#333333")
+
+ private lateinit var container: LinearLayout
+ private lateinit var emptyLayout: LinearLayout
+ private lateinit var scrollView: ScrollView
+ private lateinit var clearBtn: TextView // shown/hidden dynamically
+ private var clearResetRunnable: Runnable? = null
+ private val clearHandler = android.os.Handler(android.os.Looper.getMainLooper())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.statusBarColor = cBg
+ window.navigationBarColor = cBg
+ setContentView(buildRoot())
+ DownloadTracker.downloads.observe(this) { render(it) }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Root layout
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun buildRoot(): View {
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(cBg)
+ }
+ root.addView(buildTopBar())
+ root.addView(buildDivider())
+
+ val frame = android.widget.FrameLayout(this).apply {
+ layoutParams = LinearLayout.LayoutParams(MATCH, 0, 1f)
+ }
+ scrollView = ScrollView(this).apply {
+ layoutParams = android.widget.FrameLayout.LayoutParams(MATCH, MATCH)
+ visibility = View.GONE
+ }
+ container = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(0, dp(8), 0, dp(32))
+ }
+ scrollView.addView(container)
+ emptyLayout = buildEmptyState()
+
+ frame.addView(scrollView)
+ frame.addView(emptyLayout)
+ root.addView(frame)
+ return root
+ }
+
+ // ── Top app bar ───────────────────────────────────────────────────────────
+
+ private fun buildTopBar(): View {
+ val bar = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setBackgroundColor(cBg)
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(dp(4), dp(8), dp(8), dp(8))
+ layoutParams = LinearLayout.LayoutParams(MATCH, dp(64))
+ }
+
+ val back = ImageView(this).apply {
+ setImageResource(R.drawable.arrow_left)
+ setBackgroundResource(R.drawable.bottom_btn_ripple)
+ setPadding(dp(10), dp(10), dp(10), dp(10))
+ layoutParams = LinearLayout.LayoutParams(dp(44), dp(44))
+ setOnClickListener { finish() }
+ }
+
+ val title = TextView(this).apply {
+ text = "Downloads"
+ textSize = 22f
+ setTextColor(cOnBg)
+ setTypeface(null, android.graphics.Typeface.BOLD)
+ layoutParams = LinearLayout.LayoutParams(0, WRAP, 1f).apply {
+ marginStart = dp(4)
+ }
+ }
+
+ // Hidden by default — shown only when there's something to clear
+ clearBtn = TextView(this).apply {
+ text = "Clear"
+ textSize = 13f
+ setTextColor(cPrimary)
+ setPadding(dp(12), dp(10), dp(12), dp(10))
+ background = roundedBg(cSurface, 20f)
+ visibility = View.GONE
+ setOnClickListener {
+ // 1. Show "Clear done" immediately
+ this.text = "Clear done"
+ setTextColor(cSuccess)
+
+ // 2. Cancel any pending reset
+ clearResetRunnable?.let { clearHandler.removeCallbacks(it) }
+
+ // 3. Actually clear the list
+ DownloadTracker.clearFinished()
+
+ // 4. After 2s, fade back to "Clear" (or hide if nothing left)
+ clearResetRunnable = Runnable {
+ animate()
+ .alpha(0f)
+ .setDuration(200)
+ .withEndAction {
+ text = "Clear"
+ setTextColor(cPrimary)
+ // visibility is controlled by render() — if nothing left it stays gone
+ alpha = 1f
+ }
+ .start()
+ clearResetRunnable = null
+ }
+ clearHandler.postDelayed(clearResetRunnable!!, 2000)
+ }
+ }
+
+ bar.addView(back)
+ bar.addView(title)
+ bar.addView(clearBtn)
+ return bar
+ }
+
+ private fun buildDivider() = View(this).apply {
+ setBackgroundColor(cOutline)
+ layoutParams = LinearLayout.LayoutParams(MATCH, 1)
+ }
+
+ // ── Empty state ───────────────────────────────────────────────────────────
+
+ private fun buildEmptyState(): LinearLayout {
+ return LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ layoutParams = android.widget.FrameLayout.LayoutParams(MATCH, MATCH)
+
+ addView(ImageView(this@DownloadsActivity).apply {
+ setImageResource(R.drawable.ic_download)
+ setColorFilter(Color.parseColor("#333333"))
+ layoutParams = LinearLayout.LayoutParams(dp(64), dp(64))
+ alpha = 0.6f
+ })
+ addView(TextView(this@DownloadsActivity).apply {
+ text = "No downloads yet"
+ textSize = 18f
+ setTextColor(Color.parseColor("#666666"))
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(20) }
+ })
+ addView(TextView(this@DownloadsActivity).apply {
+ text = "Files you download will appear here"
+ textSize = 13f
+ setTextColor(Color.parseColor("#444444"))
+ gravity = Gravity.CENTER
+ layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(8) }
+ })
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Rendering
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun render(items: List) {
+ if (items.isEmpty()) {
+ scrollView.visibility = View.GONE
+ emptyLayout.visibility = View.VISIBLE
+ clearBtn.visibility = View.GONE
+ return
+ }
+
+ scrollView.visibility = View.VISIBLE
+ emptyLayout.visibility = View.GONE
+
+ // Show "Clear" only when there's at least one finished item
+ // Don't override text if we're currently showing the "Clear done" feedback
+ val hasFinished = items.any {
+ it.state == DownloadState.COMPLETED ||
+ it.state == DownloadState.FAILED ||
+ it.state == DownloadState.CANCELLED
+ }
+ if (clearBtn.visibility == View.GONE || clearBtn.text == "Clear") {
+ clearBtn.visibility = if (hasFinished) View.VISIBLE else View.GONE
+ }
+
+ container.removeAllViews()
+ items.reversed().forEach { container.addView(buildCard(it)) }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // M3-style card on black background
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun buildCard(item: DownloadItem): View {
+ val card = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ background = roundedBg(cSurface, 16f)
+ setPadding(dp(16), dp(14), dp(16), dp(14))
+ layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
+ setMargins(dp(12), dp(4), dp(12), dp(4))
+ }
+ }
+
+ // ── Row 1: tonal icon · info column · action chip ─────────────────
+ val row1 = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ }
+
+ val iconWrap = android.widget.FrameLayout(this).apply {
+ background = roundedBg(cPrimaryDim, 12f)
+ layoutParams = LinearLayout.LayoutParams(dp(40), dp(40)).apply { marginEnd = dp(14) }
+ }
+ iconWrap.addView(ImageView(this).apply {
+ setImageResource(iconForMime(item.mimeType))
+ setColorFilter(stateColor(item.state))
+ layoutParams = android.widget.FrameLayout.LayoutParams(dp(22), dp(22), Gravity.CENTER)
+ })
+
+ val info = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = LinearLayout.LayoutParams(0, WRAP, 1f)
+ }
+ info.addView(TextView(this).apply {
+ text = item.fileName
+ textSize = 14f
+ setTextColor(cOnBg)
+ setTypeface(null, android.graphics.Typeface.BOLD)
+ maxLines = 2
+ ellipsize = android.text.TextUtils.TruncateAt.END
+ })
+
+ // Badge line for non-running states
+ val badge = when (item.state) {
+ DownloadState.QUEUED -> "Waiting…" to cOnSurfaceVar
+ DownloadState.COMPLETED -> "Complete · ${formatBytes(item.downloadedBytes)}" to cSuccess
+ DownloadState.FAILED -> "Failed: ${item.error ?: "Unknown error"}" to cError
+ DownloadState.CANCELLED -> "Cancelled" to cOnSurfaceVar
+ DownloadState.RUNNING -> null
+ }
+ if (badge != null) {
+ info.addView(TextView(this).apply {
+ text = badge.first
+ textSize = 12f
+ setTextColor(badge.second)
+ layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(3) }
+ })
+ }
+
+ row1.addView(iconWrap)
+ row1.addView(info)
+ buildActionChip(item)?.let { row1.addView(it) }
+ card.addView(row1)
+
+ // ── Progress section (RUNNING) ─────────────────────────────────────
+ if (item.state == DownloadState.RUNNING) {
+ val pb = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
+ layoutParams = LinearLayout.LayoutParams(MATCH, dp(4)).apply { topMargin = dp(12) }
+ max = 100
+ if (item.totalBytes > 0) {
+ isIndeterminate = false
+ progress = ((item.downloadedBytes * 100) / item.totalBytes).toInt()
+ progressTintList = android.content.res.ColorStateList.valueOf(cPrimary)
+ progressBackgroundTintList = android.content.res.ColorStateList.valueOf(cSurfaceHigh)
+ } else {
+ isIndeterminate = true
+ indeterminateTintList = android.content.res.ColorStateList.valueOf(cPrimary)
+ }
+ }
+ card.addView(pb)
+
+ card.addView(TextView(this).apply {
+ text = buildString {
+ append(formatBytes(item.downloadedBytes))
+ if (item.totalBytes > 0) {
+ append(" of ${formatBytes(item.totalBytes)}")
+ append(" (${(item.downloadedBytes * 100 / item.totalBytes).toInt()}%)")
+ }
+ if (item.speedBytesPerSec > 0) append(" ${formatSpeed(item.speedBytesPerSec)}")
+ if (item.etaSeconds > 0) append(" · ${formatEta(item.etaSeconds)} left")
+ }
+ textSize = 11f
+ setTextColor(cOnSurfaceVar)
+ layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(6) }
+ })
+ }
+
+ return card
+ }
+
+ // ── Action chip ───────────────────────────────────────────────────────────
+
+ private fun buildActionChip(item: DownloadItem): View? {
+ val lp = LinearLayout.LayoutParams(WRAP, dp(32)).apply { marginStart = dp(10) }
+ fun chip(label: String, fg: Int, bg: Int, action: () -> Unit) =
+ TextView(this).apply {
+ text = label; textSize = 12f; setTextColor(fg)
+ background = roundedBg(bg, 8f)
+ setPadding(dp(10), 0, dp(10), 0)
+ gravity = Gravity.CENTER
+ layoutParams = lp
+ setOnClickListener { action() }
+ }
+ return when (item.state) {
+ DownloadState.RUNNING, DownloadState.QUEUED ->
+ chip("Cancel", cError, Color.parseColor("#2A0A0A")) {
+ DownloadTracker.update(item.id) { state = DownloadState.CANCELLED }
+ startService(Intent(this, DownloadService::class.java).apply {
+ action = DownloadService.ACTION_CANCEL
+ putExtra(DownloadService.EXTRA_DL_ID, item.id)
+ })
+ }
+ DownloadState.COMPLETED ->
+ chip("Open", cSuccess, Color.parseColor("#0A2010")) { openFile(item) }
+ DownloadState.FAILED, DownloadState.CANCELLED ->
+ chip("Remove", cOnSurfaceVar, cSurfaceHigh) { DownloadTracker.remove(item.id) }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // File open
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun openFile(item: DownloadItem) {
+ val path = item.filePath ?: return
+ try {
+ val uri: Uri = if (path.startsWith("content://")) Uri.parse(path)
+ else queryMediaStoreUri(path) ?: return
+ startActivity(Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, item.mimeType)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ })
+ } catch (_: Exception) {
+ Toast.makeText(this, "No app found to open this file", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun queryMediaStoreUri(path: String): Uri? = runCatching {
+ contentResolver.query(
+ MediaStore.Files.getContentUri("external"),
+ arrayOf(MediaStore.MediaColumns._ID),
+ "${MediaStore.MediaColumns.DATA} = ?", arrayOf(path), null
+ )?.use { c ->
+ if (c.moveToFirst())
+ ContentUris.withAppendedId(MediaStore.Files.getContentUri("external"), c.getLong(0))
+ else null
+ }
+ }.getOrNull()
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
+ private val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
+
+ private fun dp(v: Int) = (v * resources.displayMetrics.density + 0.5f).toInt()
+
+ private fun roundedBg(color: Int, cornerDp: Float): GradientDrawable =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(color)
+ cornerRadius = cornerDp * resources.displayMetrics.density
+ }
+
+ private fun iconForMime(mime: String): Int = when {
+ mime.startsWith("image/") -> android.R.drawable.ic_menu_gallery
+ mime.startsWith("video/") -> android.R.drawable.ic_media_play
+ mime.startsWith("audio/") -> android.R.drawable.ic_media_play
+ mime == "application/pdf" -> android.R.drawable.ic_menu_view
+ mime.contains("zip") || mime.contains("archive") -> android.R.drawable.ic_menu_save
+ else -> android.R.drawable.ic_menu_save
+ }
+
+ private fun stateColor(s: DownloadState): Int = when (s) {
+ DownloadState.RUNNING -> cPrimary
+ DownloadState.COMPLETED -> cSuccess
+ DownloadState.FAILED -> cError
+ DownloadState.CANCELLED,
+ DownloadState.QUEUED -> cOnSurfaceVar
+ }
+
+ private fun formatBytes(b: Long): String = when {
+ b < 1_024L -> "$b B"
+ b < 1_048_576L -> "${"%.1f".format(b / 1_024.0)} KB"
+ b < 1_073_741_824L -> "${"%.1f".format(b / 1_048_576.0)} MB"
+ else -> "${"%.2f".format(b / 1_073_741_824.0)} GB"
+ }
+ private fun formatSpeed(bps: Long) = "${formatBytes(bps)}/s"
+ private fun formatEta(sec: Long): String = when {
+ sec < 60 -> "${sec}s"
+ sec < 3600 -> "${sec / 60}m ${sec % 60}s"
+ else -> "${sec / 3600}h ${(sec % 3600) / 60}m"
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/MainActivity.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/MainActivity.kt
similarity index 61%
rename from app/src/main/java/space/karrarnazim/ConsoleFlow/MainActivity.kt
rename to app/src/main/java/space/karrarnazim/ConsoleFlow/ui/MainActivity.kt
index e45a4e6..88a0602 100644
--- a/app/src/main/java/space/karrarnazim/ConsoleFlow/MainActivity.kt
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/MainActivity.kt
@@ -2,222 +2,51 @@ package space.karrarnazim.ConsoleFlow
import android.Manifest
import android.annotation.SuppressLint
-import android.app.DownloadManager
-import android.content.ClipData
+import android.app.Activity
+import android.content.*
import android.content.ClipboardManager
-import android.content.Context
-import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Paint
-import android.graphics.RectF
-import android.graphics.Typeface
-import android.net.Uri
-import android.net.http.SslError
-import android.os.Build
-import android.os.Bundle
-import android.os.Environment
-import android.os.Handler
-import android.os.Looper
-import android.speech.RecognizerIntent
-import android.util.Patterns
-import android.util.TypedValue
+import android.content.pm.*
+import android.graphics.*
+import android.graphics.drawable.*
+import android.net.*
+import android.os.*
+import android.provider.*
+import android.text.*
import android.view.*
-import android.view.inputmethod.InputMethodManager
+import android.view.accessibility.*
+import android.view.inputmethod.*
import android.webkit.*
+import android.webkit.CookieManager
import android.widget.*
-import androidx.activity.addCallback
+import androidx.activity.result.*
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.app.AppCompatActivity
+import androidx.activity.*
+import androidx.fragment.app.*
+import androidx.webkit.*
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsControllerCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-import com.google.android.material.bottomsheet.BottomSheetDialog
+import android.util.Patterns
+import android.speech.RecognizerIntent
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import org.json.JSONArray
-import org.json.JSONObject
-import java.io.ByteArrayInputStream
-import java.io.File
-import java.io.FileOutputStream
-import java.io.Serializable
-import java.net.URLEncoder
-import java.util.concurrent.ExecutorService
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
-
-// ─────────────────────────────────────────────────────────────────────────────
-// ثوابت المشروع
-// ─────────────────────────────────────────────────────────────────────────────
-
-private const val PREFS_NAME = "ConsoleFlowPrefs"
-private const val HOME_URL_CONST = "about:blank"
-private const val MAX_LIVE_WEBVIEWS = 6 // حد أقصى للـ WebViews الحية في الذاكرة
-
-// ─────────────────────────────────────────────────────────────────────────────
-// بيانات التبويب والمجموعة
-// ─────────────────────────────────────────────────────────────────────────────
-
-data class TabState(
- val id: Int,
- var title: String = "New Tab",
- var url: String = "",
- var hasThumbnail: Boolean = false,
- var thumbnailUrl: String? = null
-) : Serializable {
- @Transient var ramThumbnail: Bitmap? = null
- @Transient var faviconBitmap: Bitmap? = null
-}
-
-data class TabGroup(
- val id: Int,
- var name: String,
- val tabs: MutableList = mutableListOf()
-) : Serializable
-
-// دالة واحدة موحّدة — FIX #13: أزلنا الدالة المكررة isHomeStateUrl()
-fun isHomeUrl(url: String?): Boolean {
- if (url.isNullOrEmpty()) return true
- return url == HOME_URL_CONST || url == "about:blank" ||
- url == "error://page" || url.startsWith("error://")
-}
-
-
-private fun generateHomePreviewBitmap(width: Int = 540, height: Int = 900): Bitmap {
- val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
- val canvas = Canvas(bitmap)
-
- canvas.drawColor(Color.BLACK)
-
- val cardPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#2A2D34") }
- val accentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#3A3A3A") }
- val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.parseColor("#E5E5E5")
- textSize = 40f
- typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
- }
- val mutedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.parseColor("#7A7A7A")
- textSize = 22f
- }
-
- fun roundRect(l: Float, t: Float, r: Float, b: Float, radius: Float, paint: Paint) {
- canvas.drawRoundRect(RectF(l, t, r, b), radius, radius, paint)
- }
-
- val settingsStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE
- style = Paint.Style.STROKE
- strokeWidth = 8f
- strokeCap = Paint.Cap.ROUND
- }
- canvas.drawRoundRect(RectF(width - 92f, 22f, width - 22f, 92f), 18f, 18f, accentPaint)
- canvas.drawLine(width - 72f, 42f, width - 42f, 42f, settingsStroke)
- canvas.drawLine(width - 72f, 62f, width - 52f, 62f, settingsStroke)
- canvas.drawLine(width - 72f, 82f, width - 62f, 82f, settingsStroke)
- canvas.drawLine(width - 50f, 28f, width - 50f, 34f, settingsStroke)
- canvas.drawLine(width - 30f, 48f, width - 30f, 54f, settingsStroke)
- canvas.drawLine(width - 50f, 68f, width - 50f, 74f, settingsStroke)
-
- roundRect(34f, 168f, width - 34f, 326f, 72f, cardPaint)
-
- val gPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- style = Paint.Style.STROKE; strokeWidth = 12f; strokeCap = Paint.Cap.ROUND
- }
- val gx = 112f; val gy = 247f; val gr = 30f
- gPaint.color = Color.parseColor("#EA4335"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr, 20f, 82f, false, gPaint)
- gPaint.color = Color.parseColor("#FBBC05"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr,102f, 78f, false, gPaint)
- gPaint.color = Color.parseColor("#34A853"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr,180f, 80f, false, gPaint)
- gPaint.color = Color.parseColor("#4285F4"); canvas.drawArc(gx-gr,gy-gr,gx+gr,gy+gr,260f, 74f, false, gPaint)
- canvas.drawText("Search", 184f, 258f, textPaint)
-
- fun iconTile(x: Float, y: Float) { roundRect(x, y, x+58f, y+58f, 18f, accentPaint) }
- iconTile(width - 222f, 221f); iconTile(width - 144f, 221f)
-
- val qrX = width - 204f; val qrY = 237f
- val qrPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
- repeat(3) { row -> repeat(3) { col ->
- canvas.drawRect(qrX+col*10f, qrY+row*10f, qrX+col*10f+7f, qrY+row*10f+7f, qrPaint)
- }}
- canvas.drawRect(qrX+28f, qrY+10f, qrX+33f, qrY+15f, qrPaint)
- canvas.drawRect(qrX+20f, qrY+28f, qrX+25f, qrY+33f, qrPaint)
-
- val micX = width - 122f
- val micPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE; style = Paint.Style.STROKE
- strokeWidth = 7f; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
- }
- canvas.drawRoundRect(RectF(micX+16f, 234f, micX+38f, 272f), 11f, 11f, micPaint)
- canvas.drawArc(RectF(micX+11f, 232f, micX+43f, 270f), 0f, 180f, false, micPaint)
- canvas.drawLine(micX+27f, 272f, micX+27f, 289f, micPaint)
- canvas.drawLine(micX+16f, 289f, micX+38f, 289f, micPaint)
-
- canvas.drawText("Bookmarks", 40f, 406f, mutedPaint)
-
- val sample = listOf(
- Triple("GitHub", Color.parseColor("#FFFFFF"), "GH"),
- Triple("Stack Overflow",Color.parseColor("#FFFFFF"), "SO"),
- Triple("MDN", Color.parseColor("#FFFFFF"), "MDN"),
- Triple("npm", Color.parseColor("#C63636"), "npm"),
- Triple("Docker", Color.parseColor("#2496ED"), "D"),
- Triple("Dev.to", Color.parseColor("#FFFFFF"), "DEV")
- )
- val lefts = floatArrayOf(36f, 206f, 376f)
- val tops = floatArrayOf(446f, 650f)
- var idx = 0
- for (row in tops) {
- for (col in lefts) {
- if (idx >= sample.size) break
- val (name, bg, txt) = sample[idx++]
- val tilePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = bg }
- roundRect(col, row, col+96f, row+96f, 26f, tilePaint)
- val textColor = if (name == "npm") Color.WHITE else Color.BLACK
- val lp = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = textColor; textAlign = Paint.Align.CENTER
- textSize = if (txt.length > 2) 22f else 28f
- typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
- }
- canvas.drawText(txt, col+48f, row+58f, lp)
- canvas.drawText(name, col+48f, row+126f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 19f
- })
- }
- }
- return bitmap
-}
-
-
-private fun createMicBitmap(sizePx: Int): Bitmap {
- val size = maxOf(sizePx, 48)
- val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
- val canvas = Canvas(bitmap)
- val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE; style = Paint.Style.STROKE
- strokeWidth = maxOf(4.2f, size * 0.11f)
- strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
- }
- val w = size.toFloat(); val h = size.toFloat()
- canvas.drawRoundRect(RectF(w*0.34f, h*0.12f, w*0.66f, h*0.64f), w*0.16f, w*0.16f, paint)
- canvas.drawLine(w*0.5f, h*0.64f, w*0.5f, h*0.78f, paint)
- canvas.drawLine(w*0.35f, h*0.78f, w*0.65f, h*0.78f, paint)
- canvas.drawArc(RectF(w*0.28f, h*0.10f, w*0.72f, h*0.66f), 200f, 140f, false, paint)
- return bitmap
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// النشاط الرئيسي
-// ─────────────────────────────────────────────────────────────────────────────
+import androidx.lifecycle.*
+import com.google.android.material.tabs.*
+import androidx.appcompat.app.*
+import androidx.appcompat.widget.*
+import androidx.core.content.*
+import androidx.core.view.*
+import androidx.recyclerview.widget.*
+import androidx.swiperefreshlayout.widget.*
+import com.google.android.material.bottomsheet.*
+import com.google.android.material.dialog.*
+import com.google.android.material.floatingactionbutton.*
+import com.google.android.material.textfield.*
+import okhttp3.*
+import org.json.*
+import java.io.*
+import java.net.*
+import java.util.*
+import java.util.concurrent.*
class MainActivity : AppCompatActivity() {
@@ -253,6 +82,8 @@ class MainActivity : AppCompatActivity() {
// ── الإعدادات والمديرون ────────────────────────────────────────────────
private lateinit var prefsManager: PrefsManager
+ private lateinit var bookmarkRepository: BookmarkRepository
+ private lateinit var historyRepository: HistoryRepository
// FIX #9 — إضافة timeouts لـ OkHttp لمنع block لا نهائي
private val okClient = OkHttpClient.Builder()
@@ -292,20 +123,35 @@ class MainActivity : AppCompatActivity() {
return normalized in LOCALHOST_HOSTS
}
- // ── مجموعات التبويبات والتبويب النشط ───────────────────────────────────
- private var tabGroups = mutableListOf()
- private var activeGroupId = 0
- private var activeTabId = 0
- private var nextTabId = 1
- private var nextGroupId = 1
- private lateinit var tabAdapter: TabAdapter
+ // ── إدارة الجلسات والتبويبات ───────────────────────────────────────────
+ private lateinit var sessionManager: BrowserSessionManager
+ private lateinit var webViewFactory: BrowserWebViewFactory
+
+ private var tabGroups: MutableList
+ get() = sessionManager.tabGroups
+ set(value) {
+ sessionManager.tabGroups.clear()
+ sessionManager.tabGroups.addAll(value)
+ }
+
+ private var activeGroupId: Int
+ get() = sessionManager.activeGroupId
+ set(value) { sessionManager.activeGroupId = value }
+
+ private var activeTabId: Int
+ get() = sessionManager.activeTabId
+ set(value) { sessionManager.activeTabId = value }
- private val currentGroup: TabGroup? get() = tabGroups.find { it.id == activeGroupId }
- private fun tabThumbnailFile(tabId: Int) = File(cacheDir, "thumb_$tabId.webp")
- private fun homePreviewFile(width: Int, height: Int) = File(cacheDir, "home_preview_${width}x${height}.webp")
- private fun homePreviewSigFile(width: Int, height: Int) = File(cacheDir, "home_preview_${width}x${height}.sig")
- private fun hasCachedTabThumbnail(tabId: Int) = tabThumbnailFile(tabId).exists()
+ private var nextTabId: Int
+ get() = sessionManager.nextTabId
+ set(value) { sessionManager.nextTabId = value }
+ private var nextGroupId: Int
+ get() = sessionManager.nextGroupId
+ set(value) { sessionManager.nextGroupId = value }
+
+ private val currentGroup: TabGroup? get() = sessionManager.currentGroup
+ private lateinit var tabAdapter: TabAdapter
// ── عقود نتائج الأذونات ومسح QR ────────────────────────────────────────
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -314,6 +160,11 @@ class MainActivity : AppCompatActivity() {
else webPermissionRequest?.deny()
}
+ // POST_NOTIFICATIONS permission for download completion alerts (API 33+)
+ private val notifPermLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { /* granted or denied — downloads work either way */ }
+
private val qrScanLauncher = registerForActivityResult(ScanContract()) { result: ScanIntentResult ->
result.contents?.let { scanned -> navigateTo(scanned) }
}
@@ -335,14 +186,122 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
prefsManager = PrefsManager(this)
+ bookmarkRepository = BookmarkRepository(prefsManager)
+ historyRepository = HistoryRepository(prefsManager)
// FIX #10 — fixed thread pool محدود بعدد cores
ioExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors().coerceIn(2, 4)
)
+ sessionManager = BrowserSessionManager()
+ webViewFactory = BrowserWebViewFactory(
+ activity = this,
+ prefsManager = prefsManager,
+ okClient = okClient,
+ currentWebViewProvider = { currentWebView },
+ isHomeUrl = { isHomeUrl(it) },
+ isLocalhostHost = { isLocalhostHost(it) },
+ noInterceptDomains = NO_INTERCEPT_DOMAINS,
+ bookmarkRepository = bookmarkRepository,
+ onOpenNewTab = { openNewTab(it) },
+ onMarkHomeOverlayDirty = { homeOverlayDirty = true },
+ onInvalidateHomePreviewCache = { invalidateHomePreviewCache() },
+ onNavigate = { navigateTo(it) },
+ onSetSwipeRefresh = { enabled -> swipeRefresh.isEnabled = enabled },
+ onPageStartedUi = { _, _, url ->
+ keepCursorAlive()
+ progressBar.visibility = View.VISIBLE
+ textUrl.setText(if (isHomeUrl(url)) "" else url)
+ updateBookmarkIcon(url ?: "")
+ if (isHomeUrl(url)) setTopBarVisible(false) else setTopBarVisible(true)
+ },
+ onProgressChangedUi = { _, progress ->
+ progressBar.progress = progress
+ },
+ onShowCustomViewUi = { view, callback ->
+ customView = view
+ customViewCallback = callback
+ fullscreenContainer.removeAllViews()
+ if (view != null) fullscreenContainer.addView(view)
+ fullscreenContainer.visibility = View.VISIBLE
+ webViewContainer.visibility = View.GONE
+ setFullscreen(true)
+ },
+ onHideCustomViewUi = {
+ hideCustomView()
+ },
+ onPermissionRequestUi = { request ->
+ webPermissionRequest = request
+ },
+ onPageFinishedUi = { tabId, view, url ->
+ keepCursorAlive()
+ swipeRefresh.isRefreshing = false
+ progressBar.visibility = View.INVISIBLE
+
+ url?.let { pageUrl ->
+ if (!isHomeUrl(pageUrl)) {
+ val title = view.title ?: "Unknown"
+ ioExecutor.execute { historyRepository.addHistory(title, pageUrl) }
+ }
+
+ currentGroup?.tabs?.find { it.id == tabId }?.let { tab ->
+ tab.title = view.title ?: "Tab"
+ tab.url = if (isHomeUrl(pageUrl)) HOME_URL else pageUrl
+ if (isHomeUrl(pageUrl) && tab.ramThumbnail == null) {
+ tab.ramThumbnail = getHomePreviewBitmap()
+ tab.hasThumbnail = true
+ tab.thumbnailUrl = HOME_URL
+ }
+ }
+ refreshTabsRecycler()
+ savePersistentTabs()
+ }
+
+ if (prefsManager.desktopMode) {
+ view.evaluateJavascript(
+ "(function(){" +
+ "var meta=document.querySelector('meta[name=\"viewport\"]');" +
+ "if(meta){meta.setAttribute('content','width=1024');}" +
+ "else{var nm=document.createElement('meta');nm.name='viewport';" +
+ "nm.content='width=1024';document.head.appendChild(nm);" +
+ "}})()",
+ null
+ )
+ }
+ },
+ onReceivedIconUi = { tabId, icon ->
+ currentGroup?.tabs?.find { it.id == tabId }?.let { tab ->
+ tab.faviconBitmap = icon
+ refreshTabsRecycler()
+ }
+ },
+ onReceivedErrorUi = { url -> showErrorOverlay(url) },
+ onApplyConsoleTools = { view -> applyConsoleTools(view) },
+ onDownloadStart = { url, userAgent, contentDisposition, mimeType, contentLength ->
+ handleDownloadRequest(url, userAgent, contentDisposition, mimeType, contentLength)
+ }
+ )
+
initViews()
+ cursorController = CursorController(this).also { it.attach() }
+ inputController = buildInputController().also { it.setCursorController(cursorController) }
+
+ // Refresh menu mini-list while Downloads tab is open
+ DownloadTracker.downloads.observe(this) {
+ if (cachedMenuSheet?.isShowing == true) {
+ val pageDl = cachedMenuSheetView?.findViewById(R.id.menuPageDownloads)
+ if (pageDl?.visibility == View.VISIBLE) populateMenuDownloadsList()
+ }
+ }
setupListeners()
+ window.decorView.setOnGenericMotionListener { _, event ->
+ if (event.isFromSource(InputDevice.SOURCE_JOYSTICK)) {
+ cursorArmed = true
+ }
+ keepCursorAlive()
+ inputController.onGenericMotion(event)
+ }
setTopBarVisible(false, immediate = true)
val intentUrl = intent?.data?.toString()
@@ -363,6 +322,7 @@ class MainActivity : AppCompatActivity() {
val restoredState = savedInstanceState.getBundle("active_webview_state")
val wv = ensureWebViewForTab(activeTab, restoredState)
webViewContainer.addView(wv)
+ wv.requestFocus()
}
updateGroupsUI()
refreshTabsRecycler()
@@ -378,13 +338,14 @@ class MainActivity : AppCompatActivity() {
onBackPressedDispatcher.addCallback(this) {
when {
- tabsOverlay.visibility == View.VISIBLE -> tabsOverlay.visibility = View.GONE
+ tabsOverlay.visibility == View.VISIBLE -> { tabsOverlay.visibility = View.GONE; keepCursorAlive() }
nativeOverlayContainer.visibility == View.VISIBLE -> hideNativeOverlays()
topBar.visibility == View.VISIBLE && isHomeUrl(currentWebView?.url) -> setTopBarVisible(false)
customView != null -> hideCustomView()
findBar.visibility == View.VISIBLE -> {
findBar.visibility = View.GONE
currentWebView?.clearMatches()
+ keepCursorAlive()
}
currentWebView?.canGoBack() == true -> currentWebView?.goBack()
else -> finish()
@@ -419,6 +380,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
updateSearchEngineIcon()
+ keepCursorAlive()
val root = findViewById(android.R.id.content)
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
@@ -467,6 +429,8 @@ class MainActivity : AppCompatActivity() {
webViews.clear()
ioExecutor.shutdown()
cachedMenuSheet?.dismiss()
+ inputController.release()
+ cursorController.detach()
super.onDestroy()
}
@@ -571,9 +535,9 @@ class MainActivity : AppCompatActivity() {
val width = resources.displayMetrics.widthPixels.coerceAtLeast(360)
val height = resources.displayMetrics.heightPixels.coerceAtLeast(640)
- val key = homePreviewCacheKey(width, height)
- val cacheFile = homePreviewFile(width, height)
- val sigFile = homePreviewSigFile(width, height)
+ val key = BrowserCacheFiles.homePreviewCacheKey(width, height)
+ val cacheFile = BrowserCacheFiles.homePreviewFile(cacheDir, width, height)
+ val sigFile = BrowserCacheFiles.homePreviewSigFile(cacheDir, width, height)
if (!force && cacheFile.exists() && sigFile.exists()) {
runCatching { sigFile.readText() }.getOrNull()?.let { stored ->
@@ -599,16 +563,6 @@ class MainActivity : AppCompatActivity() {
return rendered
}
- private fun homePreviewCacheKey(width: Int, height: Int): String {
- val bookmarks = prefsManager.getBookmarks().joinToString("|") { (t, u) -> "$t>$u" }
- return buildString {
- append("v4|")
- append(width).append('x').append(height).append('|')
- append(prefsManager.searchEngine).append('|')
- append(bookmarks)
- }
- }
-
private fun renderHomePreviewBitmap(width: Int, height: Int): Bitmap {
val view = buildHomeOverlay(loadFavicons = false)
val wSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY)
@@ -625,6 +579,7 @@ class MainActivity : AppCompatActivity() {
}
private fun setTopBarVisible(visible: Boolean, immediate: Boolean = false) {
+ keepCursorAlive()
if (immediate) {
topBar.alpha = if (visible) 1f else 0f
topBar.visibility = if (visible) View.VISIBLE else View.GONE
@@ -741,10 +696,22 @@ class MainActivity : AppCompatActivity() {
content.addView(topRow)
val topAction = ImageView(this).apply {
+ setImageResource(R.drawable.logo_consoleflow)
+ layoutParams = LinearLayout.LayoutParams(
+ (130 * dp).toInt(),
+ (25 * dp).toInt()
+ ).apply { marginEnd = (8 * dp).toInt() }
+ scaleType = ImageView.ScaleType.FIT_START
+ alpha = 0.95f
+ }
+ topRow.addView(topAction)
+ topRow.addView(View(this).apply { layoutParams = LinearLayout.LayoutParams(0, 0, 1f) })
+
+ val settingsBtn = ImageView(this).apply {
setImageResource(R.drawable.ic_settings)
setColorFilter(Color.WHITE)
layoutParams = LinearLayout.LayoutParams((40*dp).toInt(), (40*dp).toInt())
- alpha = 0.95f
+ alpha = 0.85f
setPadding((9*dp).toInt(), (9*dp).toInt(), (9*dp).toInt(), (9*dp).toInt())
setBackgroundResource(R.drawable.bottom_btn_ripple)
scaleType = ImageView.ScaleType.FIT_CENTER
@@ -753,8 +720,7 @@ class MainActivity : AppCompatActivity() {
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
}
- topRow.addView(topAction)
- topRow.addView(View(this).apply { layoutParams = LinearLayout.LayoutParams(0, 0, 1f) })
+ topRow.addView(settingsBtn)
val searchBar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
@@ -827,7 +793,7 @@ class MainActivity : AppCompatActivity() {
}
content.addView(fixedHeader)
- fun bookmarkGrid(items: List>, loadRemoteIcons: Boolean) {
+ fun bookmarkGrid(items: List>, loadRemoteIcons: Boolean) {
if (items.isEmpty()) return
val grid = GridLayout(this).apply {
columnCount = 4; useDefaultMargins = true
@@ -878,7 +844,7 @@ class MainActivity : AppCompatActivity() {
)
bookmarkGrid(fixedSites, loadFavicons)
- val userBookmarks = prefsManager.getBookmarks().take(12)
+ val userBookmarks = bookmarkRepository.getBookmarks().take(12)
if (userBookmarks.isNotEmpty()) {
val userHeader = TextView(this).apply {
text = "MY BOOKMARKS"
@@ -995,6 +961,7 @@ class MainActivity : AppCompatActivity() {
}
private fun hideNativeOverlays(immediate: Boolean = false) {
+ keepCursorAlive()
nativeHomeOverlay?.let { fadeOverlay(it, false, immediate) }
nativeErrorOverlay?.let { fadeOverlay(it, false, immediate) }
nativeOverlayContainer.visibility = View.GONE
@@ -1004,6 +971,7 @@ class MainActivity : AppCompatActivity() {
// FIX #7 — showHomeOverlay تتحقق من dirty flag وتُعيد البناء عند الحاجة فقط
private fun showHomeOverlay() {
+ keepCursorAlive()
if (homeOverlayDirty) {
buildNativeOverlays()
homeOverlayDirty = false
@@ -1024,6 +992,7 @@ class MainActivity : AppCompatActivity() {
}
private fun showErrorOverlay(url: String?) {
+ keepCursorAlive()
lastErrorUrl = url
setTopBarVisible(false)
nativeHomeOverlay?.let { fadeOverlay(it, false) }
@@ -1043,103 +1012,31 @@ class MainActivity : AppCompatActivity() {
// FIX #1 — أخذ snapshot كامل على الـ main thread قبل الكتابة في الخلفية
// يمنع ConcurrentModificationException الصامت
- private fun savePersistentTabs() {
- // نبني نسخة ثابتة من البيانات على الـ main thread
- val snapshot = tabGroups.map { g -> g.copy(tabs = g.tabs.toMutableList()) }
- val gId = activeGroupId
- val tId = activeTabId
- val nextTab = nextTabId
- val nextGroup = nextGroupId
-
- ioExecutor.execute {
- try {
- val groupsArray = JSONArray()
- for (group in snapshot) {
- val groupObj = JSONObject()
- groupObj.put("id", group.id)
- groupObj.put("name", group.name)
- val tabsArray = JSONArray()
- for (tab in group.tabs) {
- val tabObj = JSONObject()
- tabObj.put("id", tab.id)
- tabObj.put("title", tab.title)
- tabObj.put("url", tab.url)
- tabObj.put("hasThumb", tab.hasThumbnail)
- tabObj.put("thumbUrl", tab.thumbnailUrl)
- tabsArray.put(tabObj)
- }
- groupObj.put("tabs", tabsArray)
- groupsArray.put(groupObj)
- }
- getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
- .putString("SAVED_GROUPS", groupsArray.toString())
- .putInt("ACTIVE_GROUP", gId)
- .putInt("ACTIVE_TAB", tId)
- .putInt("NEXT_TAB_ID", nextTab)
- .putInt("NEXT_GROUP_ID", nextGroup)
- .apply()
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
+private fun savePersistentTabs() {
+ sessionManager.saveToStorage(this)
}
- private fun loadPersistentTabs(intentUrl: String?) {
- val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- val savedJson = prefs.getString("SAVED_GROUPS", null)
-
- if (savedJson != null) {
- try {
- val groupsArray = JSONArray(savedJson)
- if (groupsArray.length() > 0) {
- for (i in 0 until groupsArray.length()) {
- val gObj = groupsArray.getJSONObject(i)
- val group = TabGroup(gObj.getInt("id"), gObj.getString("name"))
-
- val tabsArray = gObj.getJSONArray("tabs")
- for (j in 0 until tabsArray.length()) {
- val tObj = tabsArray.getJSONObject(j)
- val rawUrl = tObj.getString("url")
- val tabId = tObj.getInt("id")
- val t = TabState(
- tabId,
- tObj.getString("title"),
- if (isHomeUrl(rawUrl)) HOME_URL else rawUrl,
- tObj.optBoolean("hasThumb", false) || hasCachedTabThumbnail(tabId),
- tObj.optString("thumbUrl", rawUrl)
- )
- group.tabs.add(t)
- }
- tabGroups.add(group)
- }
-
- activeGroupId = prefs.getInt("ACTIVE_GROUP", tabGroups.first().id)
- nextTabId = prefs.getInt("NEXT_TAB_ID", 100)
- nextGroupId = prefs.getInt("NEXT_GROUP_ID", 100)
+ fun loadPersistentTabs(intentUrl: String?) {
+ if (sessionManager.restoreFromStorage(this) && sessionManager.tabGroups.isNotEmpty()) {
+ val activeGroupTabs = currentGroup?.tabs
+ val preferredTabId = activeTabId
+ val activeTab = activeGroupTabs?.find { it.id == preferredTabId } ?: activeGroupTabs?.firstOrNull()
+ activeTabId = activeTab?.id ?: 0
- val activeGroupTabs = currentGroup?.tabs
- val preferredTabId = prefs.getInt("ACTIVE_TAB", activeGroupTabs?.firstOrNull()?.id ?: 0)
- val activeTab = activeGroupTabs?.find { it.id == preferredTabId } ?: activeGroupTabs?.firstOrNull()
- activeTabId = activeTab?.id ?: 0
-
- if (activeTab != null) {
- val wv = ensureWebViewForTab(activeTab)
- if (webViewContainer.indexOfChild(wv) == -1) webViewContainer.addView(wv)
- }
+ if (activeTab != null) {
+ val wv = ensureWebViewForTab(activeTab)
+ if (webViewContainer.indexOfChild(wv) == -1) webViewContainer.addView(wv)
+ }
- updateGroupsUI()
- refreshTabsRecycler()
+ updateGroupsUI()
+ refreshTabsRecycler()
- val activeTabUrl = currentGroup?.tabs?.find { it.id == activeTabId }?.url
- if (isHomeUrl(activeTabUrl) && intentUrl.isNullOrEmpty()) showHomeOverlay()
- else hideNativeOverlays(immediate = true)
+ val activeTabUrl = currentGroup?.tabs?.find { it.id == activeTabId }?.url
+ if (isHomeUrl(activeTabUrl) && intentUrl.isNullOrEmpty()) showHomeOverlay()
+ else hideNativeOverlays(immediate = true)
- if (!intentUrl.isNullOrEmpty()) openNewTab(intentUrl)
- return
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
+ if (!intentUrl.isNullOrEmpty()) openNewTab(intentUrl)
+ return
}
createNewGroup("Default", if (!intentUrl.isNullOrEmpty()) intentUrl else HOME_URL)
}
@@ -1171,13 +1068,13 @@ class MainActivity : AppCompatActivity() {
}
setOnLongClickListener {
- showModernPopup(group.name, listOf("Rename Group", "Delete Group")) { index ->
+ BrowserDialogHelpers.showModernPopup(context, group.name, listOf("Rename Group", "Delete Group")) { index ->
when (index) {
0 -> {
- val input = EditText(this@MainActivity).apply {
+ val input = EditText(context).apply {
setText(group.name); setTextColor(Color.WHITE)
}
- AlertDialog.Builder(this@MainActivity, R.style.DarkDialog)
+ AlertDialog.Builder(context, R.style.DarkDialog)
.setTitle("Rename Group")
.setView(input)
.setPositiveButton("Save") { _, _ ->
@@ -1187,7 +1084,7 @@ class MainActivity : AppCompatActivity() {
}
1 -> {
if (tabGroups.size == 1) {
- Toast.makeText(this@MainActivity, "Cannot delete the last group", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, "Cannot delete the last group", Toast.LENGTH_SHORT).show()
return@showModernPopup
}
group.tabs.forEach { t ->
@@ -1213,9 +1110,7 @@ class MainActivity : AppCompatActivity() {
}
private fun createNewGroup(name: String, url: String = HOME_URL) {
- val group = TabGroup(nextGroupId++, name)
- tabGroups.add(group)
- activeGroupId = group.id
+ sessionManager.createNewGroup(name)
openNewTab(url)
updateGroupsUI()
}
@@ -1229,18 +1124,11 @@ class MainActivity : AppCompatActivity() {
}
private fun sanitizeActiveTabSelection(preferredTabId: Int? = null) {
- val group = currentGroup ?: return
-
- val preferred = preferredTabId?.let { wanted ->
- group.tabs.firstOrNull { it.id == wanted }
- }
- val validActive = group.tabs.firstOrNull { it.id == activeTabId }
- val fallback = preferred ?: validActive ?: group.tabs.firstOrNull()
-
- activeTabId = fallback?.id ?: 0
+ sessionManager.sanitizeActiveTabSelection(preferredTabId)
}
private fun showTabsOverlay() {
+ keepCursorAlive()
sanitizeActiveTabSelection()
captureAndStoreThumbnail {
refreshTabsRecycler()
@@ -1249,6 +1137,7 @@ class MainActivity : AppCompatActivity() {
}
private fun openNewTab(url: String = HOME_URL) {
+ keepCursorAlive()
captureAndStoreThumbnail {
val id = nextTabId++
val newTab = TabState(id = id, title = "New Tab", url = url)
@@ -1270,6 +1159,7 @@ class MainActivity : AppCompatActivity() {
}
private fun switchToTab(tab: TabState) {
+ keepCursorAlive()
hideKeyboard()
val shouldAnimateOverlay = tabsOverlay.visibility == View.VISIBLE
@@ -1277,9 +1167,12 @@ class MainActivity : AppCompatActivity() {
val executeSwitch = {
val targetWebView = ensureWebViewForTab(tab)
activeTabId = tab.id
+ keepCursorAlive()
webViewContainer.removeAllViews()
webViewContainer.addView(targetWebView)
+ targetWebView.requestFocus() // keyboard / TV-remote / gamepad focus
+ if (::inputController.isInitialized) inputController.stopScrollLoop()
updateUIForCurrentWebView(targetWebView)
if (isHomeUrl(tab.url)) showHomeOverlay()
@@ -1309,6 +1202,7 @@ class MainActivity : AppCompatActivity() {
// FIX #6 — حذف notifyItemRemoved اليدوي، نستخدم refreshTabsRecycler مع DiffUtil
private fun closeTab(tab: TabState) {
+ keepCursorAlive()
val group = currentGroup ?: return
val idx = group.tabs.indexOfFirst { it.id == tab.id }
if (idx < 0) return
@@ -1318,7 +1212,7 @@ class MainActivity : AppCompatActivity() {
tab.ramThumbnail = null
tab.faviconBitmap = null
- ioExecutor.execute { tabThumbnailFile(tab.id).delete() }
+ ioExecutor.execute { BrowserCacheFiles.tabThumbnailFile(cacheDir, tab.id).delete() }
group.tabs.removeAt(idx)
@@ -1372,7 +1266,7 @@ class MainActivity : AppCompatActivity() {
}
}
- val wv = createNewWebView(tab.id)
+ val wv = webViewFactory.create(tab.id)
webViews[tab.id] = wv
if (restoreState != null) {
runCatching { wv.restoreState(restoreState) }
@@ -1390,6 +1284,7 @@ class MainActivity : AppCompatActivity() {
}
private fun updateUIForCurrentWebView(wv: WebView) {
+ keepCursorAlive()
val url = wv.url ?: HOME_URL
textUrl.setText(if (isHomeUrl(url)) "" else url)
updateBookmarkIcon(url)
@@ -1416,7 +1311,7 @@ class MainActivity : AppCompatActivity() {
val tabId = activeTabId
val currentUrl = runCatching { wv.url ?: HOME_URL }.getOrDefault(HOME_URL)
- val file = tabThumbnailFile(tabId)
+ val file = BrowserCacheFiles.tabThumbnailFile(cacheDir, tabId)
val tabRef = currentGroup?.tabs?.find { it.id == tabId }
if (file.exists() && tabRef?.thumbnailUrl == currentUrl) {
@@ -1462,267 +1357,6 @@ class MainActivity : AppCompatActivity() {
// إنشاء WebView جديد مع جميع الإعدادات والمستمعين
// ─────────────────────────────────────────────────────────────────────────
- @SuppressLint("SetJavaScriptEnabled")
- private fun createNewWebView(tabId: Int): WebView {
- val wv = WebView(this)
- wv.layoutParams = FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.MATCH_PARENT
- )
-
- with(wv.settings) {
- javaScriptEnabled = true
- domStorageEnabled = true
- databaseEnabled = true
- setSupportZoom(true)
- builtInZoomControls = true
- displayZoomControls = false
- mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
- mediaPlaybackRequiresUserGesture = false
- cacheMode = WebSettings.LOAD_DEFAULT
- }
-
- applyUserAgentToWebView(wv)
- wv.addJavascriptInterface(SearchBridge(), "Android")
-
- wv.setOnCreateContextMenuListener { _, _, _ ->
- val result = wv.hitTestResult
- if (result.type == WebView.HitTestResult.SRC_ANCHOR_TYPE ||
- result.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
- val url = result.extra ?: return@setOnCreateContextMenuListener
- showModernPopup(url, listOf("Open in New Tab", "Copy Link", "Bookmark Link", "Share")) { index ->
- when (index) {
- 0 -> { openNewTab(url); Toast.makeText(this@MainActivity, "Opened in new tab", Toast.LENGTH_SHORT).show() }
- 1 -> {
- (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager)
- .setPrimaryClip(ClipData.newPlainText("URL", url))
- Toast.makeText(this@MainActivity, "Copied", Toast.LENGTH_SHORT).show()
- }
- 2 -> {
- prefsManager.toggleBookmark("Bookmark", url)
- homeOverlayDirty = true
- invalidateHomePreviewCache()
- Toast.makeText(this@MainActivity, "Bookmarked", Toast.LENGTH_SHORT).show()
- }
- 3 -> startActivity(Intent.createChooser(
- Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, url) },
- "Share"
- ))
- }
- }
- }
- }
-
- wv.webViewClient = object : WebViewClient() {
- override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
- val url = req.url.toString()
- if (url.startsWith("http") || url.startsWith("file:")) return false
- return try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))); true }
- catch (_: Exception) { true }
- }
-
- override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
- if (view == currentWebView) {
- progressBar.visibility = View.VISIBLE
- textUrl.setText(if (isHomeUrl(url)) "" else url)
- updateBookmarkIcon(url ?: "")
- if (isHomeUrl(url)) setTopBarVisible(false) else setTopBarVisible(true)
- }
- }
-
- override fun onPageFinished(view: WebView, url: String?) {
- if (view == currentWebView) {
- swipeRefresh.isRefreshing = false
- progressBar.visibility = View.INVISIBLE
- }
-
- url?.let { pageUrl ->
- // FIX #8 — نقل addHistory للخلفية (كانت تعمل على main thread مع O(n) JSON)
- if (!isHomeUrl(pageUrl)) {
- val title = view.title ?: "Unknown"
- ioExecutor.execute { prefsManager.addHistory(title, pageUrl) }
- }
-
- currentGroup?.tabs?.find { t -> t.id == tabId }?.let { tab ->
- tab.title = view.title ?: "Tab"
- tab.url = if (isHomeUrl(pageUrl)) HOME_URL else pageUrl
- if (isHomeUrl(pageUrl) && tab.ramThumbnail == null) {
- tab.ramThumbnail = getHomePreviewBitmap()
- tab.hasThumbnail = true
- tab.thumbnailUrl = HOME_URL
- }
- }
- savePersistentTabs()
- }
-
- if (prefsManager.desktopMode) {
- view.evaluateJavascript(
- "(function(){" +
- "var meta=document.querySelector('meta[name=\"viewport\"]');" +
- "if(meta){meta.setAttribute('content','width=1024');}" +
- "else{var nm=document.createElement('meta');nm.name='viewport';" +
- "nm.content='width=1024';document.head.appendChild(nm);" +
- "}})()", null
- )
- }
-
- applyConsoleTools(view)
- }
-
- override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
- val url = request.url.toString()
-
- if (url == "https://eruda.local/eruda.js") {
- return try {
- WebResourceResponse("application/javascript", "utf-8", assets.open("eruda.js"))
- } catch (_: Exception) { null }
- }
-
- if (prefsManager.getBoolean("disable_intercept", false)) return null
-
- val host = request.url.host ?: ""
- if (isLocalhostHost(host)) return null
- if (NO_INTERCEPT_DOMAINS.any { host == it || host.endsWith(".$it") }) return null
-
- if (request.isForMainFrame && request.method == "GET" && url.startsWith("http")) {
- try {
- val ua = getUserAgentString()
- val reqBuilder = Request.Builder().url(url)
- request.requestHeaders.forEach { (k, v) ->
- if (k.lowercase() != "user-agent") reqBuilder.addHeader(k, v)
- }
- reqBuilder.header("User-Agent", ua)
- val cookie = CookieManager.getInstance().getCookie(url)
- if (!cookie.isNullOrEmpty()) reqBuilder.header("Cookie", cookie)
-
- okClient.newCall(reqBuilder.build()).execute().use { response ->
- val contentType = response.header("Content-Type", "") ?: ""
- if (contentType.contains("text/html")) {
- var html = response.body?.string() ?: ""
-
- html = html.replace(
- Regex("""]+http-equiv=["']Content-Security-Policy["'][^>]*>""",
- RegexOption.IGNORE_CASE), ""
- )
-
- val erudaTags = if (prefsManager.consoleEnabled) {
- "" +
- ""
-} else ""
-
-
- val customJsTag = prefsManager.customJs.takeIf { it.isNotEmpty() }
- ?.let { "" } ?: ""
-
- html = html.replaceFirst("", "$erudaTags$customJsTag",
- ignoreCase = true)
- val hdrs = response.headers.toMap().toMutableMap()
- hdrs.remove("Content-Security-Policy")
- hdrs.remove("content-security-policy")
-
- return WebResourceResponse(
- "text/html", "utf-8", response.code, "OK", hdrs,
- ByteArrayInputStream(html.toByteArray(Charsets.UTF_8))
- )
- }
- }
- } catch (_: Exception) { return null }
- }
- return super.shouldInterceptRequest(view, request)
- }
-
-
- override fun onReceivedError(view: WebView, req: WebResourceRequest, err: WebResourceError) {
- if (req.isForMainFrame) {
- runOnUiThread { showErrorOverlay(req.url.toString()) }
- }
- }
-
- override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
- AlertDialog.Builder(this@MainActivity, R.style.DarkDialog)
- .setTitle("SSL Certificate Error")
- .setMessage("The site's security certificate is not trusted. Continue anyway?")
- .setPositiveButton("Continue") { _, _ -> handler.proceed() }
- .setNegativeButton("Go Back") { _, _ -> handler.cancel() }
- .show()
- }
- }
-
- wv.webChromeClient = object : WebChromeClient() {
- override fun onProgressChanged(view: WebView, newProgress: Int) {
- if (view == currentWebView) progressBar.progress = newProgress
- }
-
- override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
- super.onReceivedIcon(view, icon)
- icon ?: return
- currentGroup?.tabs?.find { t -> t.id == tabId }?.let { tab ->
- tab.faviconBitmap = icon
- tabAdapter.updateFavicon(tabId, icon)
- }
- }
-
- override fun onShowCustomView(view: View, callback: CustomViewCallback) {
- customView = view
- customViewCallback = callback
- fullscreenContainer.addView(view)
- fullscreenContainer.visibility = View.VISIBLE
- webViewContainer.visibility = View.GONE
- setFullscreen(true)
- }
-
- override fun onHideCustomView() {
- fullscreenContainer.removeView(customView)
- fullscreenContainer.visibility = View.GONE
- webViewContainer.visibility = View.VISIBLE
- customView = null
- customViewCallback?.onCustomViewHidden()
- setFullscreen(false)
- }
-
- override fun onPermissionRequest(request: PermissionRequest) {
- webPermissionRequest = request
- val androidPerms = mutableListOf()
- request.resources.forEach {
- when (it) {
- PermissionRequest.RESOURCE_AUDIO_CAPTURE -> androidPerms.add(Manifest.permission.RECORD_AUDIO)
- PermissionRequest.RESOURCE_VIDEO_CAPTURE -> androidPerms.add(Manifest.permission.CAMERA)
- }
- }
- if (androidPerms.isNotEmpty()) requestPermissionLauncher.launch(androidPerms.toTypedArray())
- else request.grant(request.resources)
- }
- }
-
- wv.setDownloadListener { url, userAgent, contentDisposition, mimetype, _ ->
- val request = DownloadManager.Request(Uri.parse(url)).apply {
- setMimeType(mimetype)
- addRequestHeader("cookie", CookieManager.getInstance().getCookie(url))
- addRequestHeader("User-Agent", userAgent)
- setDescription("Downloading file...")
- setTitle(URLUtil.guessFileName(url, contentDisposition, mimetype))
- setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- @Suppress("DEPRECATION")
- allowScanningByMediaScanner()
- @Suppress("DEPRECATION")
- setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS,
- URLUtil.guessFileName(url, contentDisposition, mimetype))
- } else {
- setDestinationInExternalFilesDir(this@MainActivity, Environment.DIRECTORY_DOWNLOADS,
- URLUtil.guessFileName(url, contentDisposition, mimetype))
- }
- }
- (getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).enqueue(request)
- Toast.makeText(applicationContext, "Downloading File", Toast.LENGTH_LONG).show()
- }
-
- return wv
- }
-
private fun setFullscreen(fullscreen: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val controller = WindowInsetsControllerCompat(window, window.decorView)
@@ -1758,7 +1392,7 @@ class MainActivity : AppCompatActivity() {
}
textUrl.setOnLongClickListener {
- showModernPopup("URL Options", listOf("Copy URL", "Share URL")) { index ->
+ BrowserDialogHelpers.showModernPopup(this, "URL Options", listOf("Copy URL", "Share URL")) { index ->
when (index) {
0 -> {
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager)
@@ -1806,7 +1440,7 @@ class MainActivity : AppCompatActivity() {
btnBookmark.setOnClickListener {
val url = currentWebView?.url ?: return@setOnClickListener
if (isHomeUrl(url)) return@setOnClickListener
- val added = prefsManager.toggleBookmark(currentWebView?.title ?: "Bookmark", url)
+ val added = bookmarkRepository.toggleBookmark(currentWebView?.title ?: "Bookmark", url)
updateBookmarkIcon(url)
homeOverlayDirty = true // FIX #7 — سيُعيد البناء عند الظهور فقط
invalidateHomePreviewCache() // FIX #4 — إبطال cache الـ preview
@@ -1865,55 +1499,18 @@ class MainActivity : AppCompatActivity() {
// قوائم منبثقة حديثة
// ─────────────────────────────────────────────────────────────────────────
- private fun showModernPopup(title: String, items: List, onSelect: (Int) -> Unit) {
- val sheet = BottomSheetDialog(this, R.style.AppBottomSheetDialogTheme)
-
- val container = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- try { setBackgroundResource(R.drawable.bg_bottom_sheet) }
- catch (_: Exception) { setBackgroundColor(Color.parseColor("#2C2C2C")) }
- setPadding(0, 32, 0, 32)
- }
-
- val handle = View(this).apply {
- layoutParams = LinearLayout.LayoutParams(100, 12).apply {
- gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = 48
- }
- try { setBackgroundResource(R.drawable.bg_menu_item) }
- catch (_: Exception) { setBackgroundColor(Color.WHITE) }
- }
- container.addView(handle)
-
- val titleView = TextView(this).apply {
- text = title; setTextColor(Color.parseColor("#AAAAAA")); textSize = 12f
- setPadding(48, 0, 48, 24); maxLines = 1
- ellipsize = android.text.TextUtils.TruncateAt.END
- }
- container.addView(titleView)
-
- items.forEachIndexed { index, itemText ->
- val itemView = TextView(this).apply {
- text = itemText; setTextColor(Color.WHITE); textSize = 16f
- setPadding(48, 36, 48, 36)
- val outValue = TypedValue()
- context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true)
- setBackgroundResource(outValue.resourceId)
- setOnClickListener { sheet.dismiss(); onSelect(index) }
- }
- container.addView(itemView)
- }
-
- sheet.setContentView(container)
- sheet.show()
- }
// ─────────────────────────────────────────────────────────────────────────
// القائمة الرئيسية (القائمة السفلية)
// ─────────────────────────────────────────────────────────────────────────
private fun showMenuSheet() {
+ keepCursorAlive()
if (cachedMenuSheet == null) {
- cachedMenuSheet = BottomSheetDialog(this, R.style.AppBottomSheetDialogTheme)
+ cachedMenuSheet = BottomSheetDialog(this, R.style.AppBottomSheetDialogTheme).apply {
+ setOnShowListener { keepCursorAlive() }
+ setOnDismissListener { keepCursorAlive() }
+ }
cachedMenuSheetView = layoutInflater.inflate(R.layout.layout_main_menu, null)
cachedMenuSheet?.setContentView(cachedMenuSheetView!!)
@@ -1928,10 +1525,20 @@ class MainActivity : AppCompatActivity() {
)
}
cachedMenuSheetView?.findViewById(R.id.menuBookmarks)?.setOnClickListener {
- cachedMenuSheet?.dismiss(); showBookmarksDialog()
+ cachedMenuSheet?.dismiss(); BrowserDialogHelpers.showBookmarksDialog(
+ context = this,
+ getBookmarks = { bookmarkRepository.getBookmarks() },
+ openUrl = { loadUrlInstantly(it) },
+ loadFavicon = { url, imageView -> loadBookmarkFavicon(url, imageView) }
+ )
}
cachedMenuSheetView?.findViewById(R.id.menuHistory)?.setOnClickListener {
- cachedMenuSheet?.dismiss(); showHistoryDialog()
+ cachedMenuSheet?.dismiss(); BrowserDialogHelpers.showHistoryDialog(
+ context = this,
+ getHistory = { historyRepository.getHistory() },
+ openUrl = { loadUrlInstantly(it) },
+ loadFavicon = { url, imageView -> loadBookmarkFavicon(url, imageView) }
+ )
}
cachedMenuSheetView?.findViewById(R.id.menuConsoleToggle)?.setOnClickListener {
cachedMenuSheet?.dismiss(); toggleConsoleForCurrentPage()
@@ -1947,7 +1554,7 @@ class MainActivity : AppCompatActivity() {
cachedMenuSheetView?.findViewById(R.id.menuDesktopMode)?.setOnClickListener {
cachedMenuSheet?.dismiss()
prefsManager.desktopMode = !prefsManager.desktopMode
- webViews.values.forEach { applyUserAgentToWebView(it) }
+ webViews.values.forEach { WebViewSettingsHelper.applyUserAgentToWebView(this, it, prefsManager.desktopMode) }
// FIX #12 — حذف clearCache(true) الذي كان يمسح cache كل المواقع
currentWebView?.reload()
}
@@ -1963,8 +1570,26 @@ class MainActivity : AppCompatActivity() {
.setNegativeButton("Cancel", null)
.show()
}
+
+ // ── Menu page tab chips ──────────────────────────────────────
+ cachedMenuSheetView?.findViewById(R.id.menuTabTools)?.setOnClickListener {
+ activateMenuTab(isTools = true)
+ }
+ cachedMenuSheetView?.findViewById(R.id.menuTabDownloads)?.setOnClickListener {
+ activateMenuTab(isTools = false)
+ }
+
+ // ── Downloads mini page: open full activity ──────────────────
+ cachedMenuSheetView?.findViewById(R.id.menuDlOpenPage)?.setOnClickListener {
+ cachedMenuSheet?.dismiss()
+ startActivity(Intent(this, DownloadsActivity::class.java))
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
+ }
}
+ // Always reset to Tools tab on open
+ activateMenuTab(isTools = true)
+
val desktopLabel = cachedMenuSheetView?.findViewById(R.id.menuDesktopModeLabel)
if (prefsManager.desktopMode) {
desktopLabel?.text = "Desktop On"
@@ -1977,119 +1602,164 @@ class MainActivity : AppCompatActivity() {
cachedMenuSheet?.show()
}
- private fun showBookmarksDialog() {
- val bks = prefsManager.getBookmarks()
- if (bks.isEmpty()) {
- Toast.makeText(this, "No bookmarks", Toast.LENGTH_SHORT).show(); return
+ // ─────────────────────────────────────────────────────────────────────────
+ // Menu page switching
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private fun activateMenuTab(isTools: Boolean) {
+ val v = cachedMenuSheetView ?: return
+ val tabTools = v.findViewById(R.id.menuTabTools)
+ val tabDl = v.findViewById(R.id.menuTabDownloads)
+ val pageTools = v.findViewById(R.id.menuPageTools)
+ val pageDl = v.findViewById(R.id.menuPageDownloads)
+ val density = resources.displayMetrics.density
+
+ val activeChip = android.graphics.drawable.GradientDrawable().apply {
+ shape = android.graphics.drawable.GradientDrawable.RECTANGLE
+ setColor(Color.parseColor("#2B2930"))
+ cornerRadius = 10 * density
+ }
+
+ if (isTools) {
+ tabTools.setTextColor(Color.WHITE)
+ tabTools.setTypeface(null, android.graphics.Typeface.BOLD)
+ tabTools.background = activeChip
+ tabDl.setTextColor(Color.parseColor("#888888"))
+ tabDl.setTypeface(null, android.graphics.Typeface.NORMAL)
+ tabDl.background = null
+ pageTools.visibility = View.VISIBLE
+ pageDl.visibility = View.GONE
+ } else {
+ tabDl.setTextColor(Color.WHITE)
+ tabDl.setTypeface(null, android.graphics.Typeface.BOLD)
+ tabDl.background = activeChip
+ tabTools.setTextColor(Color.parseColor("#888888"))
+ tabTools.setTypeface(null, android.graphics.Typeface.NORMAL)
+ tabTools.background = null
+ pageTools.visibility = View.GONE
+ pageDl.visibility = View.VISIBLE
+ populateMenuDownloadsList()
}
- showListWithFavicons("Bookmarks", bks) { index -> loadUrlInstantly(bks[index].second) }
}
- private fun showHistoryDialog() {
- val hist = prefsManager.getHistory()
- if (hist.isEmpty()) {
- Toast.makeText(this, "No history", Toast.LENGTH_SHORT).show(); return
+ private fun populateMenuDownloadsList() {
+ val listContainer = cachedMenuSheetView
+ ?.findViewById(R.id.menuDlList) ?: return
+ listContainer.removeAllViews()
+
+ val items = DownloadTracker.downloads.value.orEmpty()
+
+ if (items.isEmpty()) {
+ listContainer.addView(TextView(this).apply {
+ text = "No downloads yet"
+ textSize = 14f
+ setTextColor(Color.parseColor("#555555"))
+ gravity = android.view.Gravity.CENTER
+ setPadding(0, menuDp(24), 0, menuDp(8))
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ })
+ return
}
- showListWithFavicons("History", hist) { index -> loadUrlInstantly(hist[index].second) }
- }
- private fun showListWithFavicons(
- title: String,
- items: List>,
- onSelect: (Int) -> Unit
- ) {
- val sheet = BottomSheetDialog(this, R.style.AppBottomSheetDialogTheme)
- val dp = resources.displayMetrics.density
- val scrollView = android.widget.ScrollView(this)
+ // Show last 4 downloads, newest first
+ items.reversed().take(4).forEach { item ->
+ listContainer.addView(buildMenuDlRow(item))
+ }
+ }
- val container = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- try { setBackgroundResource(R.drawable.bg_bottom_sheet) }
- catch (_: Exception) { setBackgroundColor(Color.parseColor("#2C2C2C")) }
- setPadding(0, 32, 0, 32)
+ private fun buildMenuDlRow(item: DownloadItem): View {
+ val density = resources.displayMetrics.density
+ val row = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = android.view.Gravity.CENTER_VERTICAL
+ setPadding(menuDp(12), menuDp(10), menuDp(12), menuDp(10))
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).apply { bottomMargin = menuDp(2) }
}
- scrollView.addView(container)
- val handle = View(this).apply {
- layoutParams = LinearLayout.LayoutParams((100*dp).toInt(), (12*dp).toInt()).apply {
- gravity = android.view.Gravity.CENTER_HORIZONTAL
- bottomMargin = (16*dp).toInt()
+ // Coloured state dot
+ val dot = View(this).apply {
+ background = android.graphics.drawable.GradientDrawable().apply {
+ shape = android.graphics.drawable.GradientDrawable.OVAL
+ setColor(menuStateColor(item.state))
+ }
+ layoutParams = LinearLayout.LayoutParams(menuDp(8), menuDp(8)).apply {
+ marginEnd = menuDp(12)
}
- try { setBackgroundResource(R.drawable.bg_menu_item) }
- catch (_: Exception) { setBackgroundColor(Color.WHITE) }
}
- container.addView(handle)
- val titleView = TextView(this).apply {
- text = title; setTextColor(Color.parseColor("#AAAAAA")); textSize = 12f
- setPadding((48*dp).toInt(), 0, (48*dp).toInt(), (24*dp).toInt())
+ // Text column: filename + status
+ val col = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = LinearLayout.LayoutParams(
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
+ )
}
- container.addView(titleView)
+ col.addView(TextView(this).apply {
+ text = item.fileName
+ textSize = 13f
+ setTextColor(Color.WHITE)
+ maxLines = 1
+ ellipsize = android.text.TextUtils.TruncateAt.END
+ })
- items.forEachIndexed { index, (itemTitle, url) ->
- val row = LinearLayout(this).apply {
- orientation = LinearLayout.HORIZONTAL
- gravity = android.view.Gravity.CENTER_VERTICAL
- setPadding((48*dp).toInt(), (20*dp).toInt(), (48*dp).toInt(), (20*dp).toInt())
- val tv = TypedValue()
- context.theme.resolveAttribute(android.R.attr.selectableItemBackground, tv, true)
- setBackgroundResource(tv.resourceId)
+ val stateText = when (item.state) {
+ DownloadState.RUNNING -> {
+ val pct = if (item.totalBytes > 0)
+ " · ${(item.downloadedBytes * 100 / item.totalBytes).toInt()}%" else ""
+ "Downloading$pct"
}
+ DownloadState.QUEUED -> "Waiting…"
+ DownloadState.COMPLETED -> "Complete · ${menuFmtBytes(item.downloadedBytes)}"
+ DownloadState.FAILED -> "Failed"
+ DownloadState.CANCELLED -> "Cancelled"
+ }
+ col.addView(TextView(this).apply {
+ text = stateText
+ textSize = 11f
+ setTextColor(menuStateColor(item.state))
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).apply { topMargin = menuDp(2) }
+ })
- val iconSize = (28*dp).toInt()
- val faviconView = android.widget.ImageView(this).apply {
- layoutParams = LinearLayout.LayoutParams(iconSize, iconSize)
- .apply { marginEnd = (16*dp).toInt() }
- setImageResource(R.drawable.ic_favicon_fallback)
- imageTintList = android.content.res.ColorStateList.valueOf(Color.WHITE)
- scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
- }
- row.addView(faviconView)
+ row.addView(dot)
+ row.addView(col)
+ return row
+ }
- val textView = TextView(this).apply {
- layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
- text = itemTitle.ifEmpty { url }
- setTextColor(Color.WHITE); textSize = 15f; maxLines = 1
- ellipsize = android.text.TextUtils.TruncateAt.END
- }
- row.addView(textView)
-
- val domain = try { android.net.Uri.parse(url).host ?: "" } catch (_: Exception) { "" }
- if (domain.isNotEmpty()) {
- ioExecutor.execute {
- try {
- val faviconUrl = "https://www.google.com/s2/favicons?domain=$domain&sz=64"
- val req = okhttp3.Request.Builder().url(faviconUrl).build()
- okClient.newCall(req).execute().use { resp ->
- val bytes = resp.body?.bytes()
- if (bytes != null && bytes.isNotEmpty()) {
- val bmp = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
- mainHandler.post {
- if (bmp != null) {
- faviconView.setImageBitmap(bmp)
- faviconView.imageTintList = null
- }
- }
- }
- }
- } catch (_: Exception) { }
- }
- }
+ private fun menuDp(v: Int) = (v * resources.displayMetrics.density + 0.5f).toInt()
- row.setOnClickListener { sheet.dismiss(); onSelect(index) }
- container.addView(row)
- }
+ private fun menuStateColor(state: DownloadState): Int = Color.parseColor(when (state) {
+ DownloadState.RUNNING -> "#6EA8DC"
+ DownloadState.COMPLETED -> "#5DB075"
+ DownloadState.FAILED -> "#F2B8B5"
+ DownloadState.CANCELLED -> "#555555"
+ DownloadState.QUEUED -> "#888888"
+ })
- sheet.setContentView(scrollView)
- sheet.show()
+ private fun menuFmtBytes(b: Long): String = when {
+ b < 1_024L -> "$b B"
+ b < 1_048_576L -> "${"%.1f".format(b / 1_024.0)} KB"
+ b < 1_073_741_824L -> "${"%.1f".format(b / 1_048_576.0)} MB"
+ else -> "${"%.2f".format(b / 1_073_741_824.0)} GB"
}
+
+
+
private fun clearData() {
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
webViews.values.forEach { it.clearCache(true); it.clearHistory() }
- prefsManager.clearHistory()
+ historyRepository.clearHistory()
invalidateHomePreviewCache() // FIX #4 — أبطل الـ cache عند مسح البيانات
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
@@ -2110,31 +1780,6 @@ class MainActivity : AppCompatActivity() {
// إعدادات User-Agent ووضع سطح المكتب
// ─────────────────────────────────────────────────────────────────────────
- private fun getUserAgentString(): String {
- val defaultUA = WebSettings.getDefaultUserAgent(this)
- return if (prefsManager.desktopMode) {
- try {
- val chromeVersion = Regex("Chrome/([0-9.]+)").find(defaultUA)?.value
- ?: "Chrome/124.0.0.0"
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
- "(KHTML, like Gecko) $chromeVersion Safari/537.36"
- } catch (_: Exception) {
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
- }
- } else {
- defaultUA
- }
- }
-
- private fun applyUserAgentToWebView(wv: WebView) {
- val isDesktop = prefsManager.desktopMode
- wv.settings.apply {
- userAgentString = getUserAgentString()
- useWideViewPort = isDesktop
- loadWithOverviewMode = isDesktop
- }
- }
// ─────────────────────────────────────────────────────────────────────────
// تحديث أيقونة محرك البحث والعلامة المرجعية
@@ -2170,6 +1815,7 @@ class MainActivity : AppCompatActivity() {
}
private fun hideCustomView() {
+ keepCursorAlive()
customViewCallback?.onCustomViewHidden()
fullscreenContainer.visibility = View.GONE
webViewContainer.visibility = View.VISIBLE
@@ -2183,71 +1829,60 @@ class MainActivity : AppCompatActivity() {
}
// ─────────────────────────────────────────────────────────────────────────
- // Console (Eruda) — سكريبتات الحقن
+ // Download handling
// ─────────────────────────────────────────────────────────────────────────
- private fun consoleInitScript(): String =
- "(function(){" +
- "window.__cfConsoleEnabled=true;" +
- "var el=document.getElementById('eruda');" +
- "if(window.__erudaInited){" +
- "try{if(window.eruda&&eruda.show)eruda.show();}catch(e){}" +
- "if(el)el.style.display='';" +
- "return;" +
- "}" +
- "if(typeof eruda!=='undefined'){" +
- "try{eruda.init();window.__erudaInited=true;window.__cfConsoleEnabled=true;" +
- "if(el)el.style.display='';}catch(e){}" +
- "return;" +
- "}" +
- "var x=new XMLHttpRequest();" +
- "x.open('GET','https://eruda.local/eruda.js',true);" +
- "x.onload=function(){" +
- "if(window.__erudaInited)return;" +
- "try{eval(x.responseText);eruda.init();window.__erudaInited=true;" +
- "window.__cfConsoleEnabled=true;if(el)el.style.display='';}catch(e){}" +
- "};" +
- "x.send();" +
- "})()"
-
- private fun consoleDisableScript(): String =
- "(function(){" +
- "window.__cfConsoleEnabled=false;" +
- "try{if(window.eruda&&eruda.hide)eruda.hide();}catch(e){}" +
- "var el=document.getElementById('eruda');" +
- "if(el){el.style.display='none';el.classList.add('__cf_console_hidden');}" +
- "})()"
-
- private fun touchHookScript(): String =
- "(function(){" +
- "if(window.__erudaTouchHooked)return;" +
- "window.__erudaTouchHooked=true;" +
- "function getErudaEl(){return document.getElementById('eruda');}" +
- "function consoleEnabled(){return window.__cfConsoleEnabled!==false;}" +
- "document.addEventListener('touchstart',function(e){" +
- "if(!consoleEnabled())return;" +
- "var el=getErudaEl();" +
- "if(el&&el.contains(e.target)){try{Android.setSwipeRefresh(false);}catch(ex){}}" +
- "},{capture:true,passive:true});" +
- "document.addEventListener('touchend',function(){" +
- "if(!consoleEnabled())return;" +
- "try{Android.setSwipeRefresh(true);}catch(ex){}" +
- "},{capture:true,passive:true});" +
- "document.addEventListener('touchcancel',function(){" +
- "if(!consoleEnabled())return;" +
- "try{Android.setSwipeRefresh(true);}catch(ex){}" +
- "},{capture:true,passive:true});" +
- "})()"
+ /**
+ * Called from BrowserWebViewFactory when WebView can't render a URL.
+ * Requests POST_NOTIFICATIONS on API 33+ (once), then fires the service.
+ */
+ private fun handleDownloadRequest(
+ url: String,
+ userAgent: String,
+ contentDisposition: String,
+ mimeType: String,
+ contentLength: Long
+ ) {
+ // Request notification permission on Android 13+ so completion
+ // notifications are visible. Downloads work either way.
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
+ != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ val fileName = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType)
+ val cookies = android.webkit.CookieManager.getInstance().getCookie(url) ?: ""
+
+ androidx.core.content.ContextCompat.startForegroundService(
+ this,
+ Intent(this, DownloadService::class.java).apply {
+ action = DownloadService.ACTION_START
+ putExtra(DownloadService.EXTRA_URL, url)
+ putExtra(DownloadService.EXTRA_FILENAME, fileName)
+ putExtra(DownloadService.EXTRA_MIME, mimeType)
+ putExtra(DownloadService.EXTRA_UA, userAgent)
+ putExtra(DownloadService.EXTRA_COOKIES, cookies)
+ }
+ )
+
+ Toast.makeText(this, "Downloading: $fileName", Toast.LENGTH_SHORT).show()
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Console (Eruda) — سكريبتات الحقن
+ // ─────────────────────────────────────────────────────────────────────────
private fun toggleConsoleForCurrentPage() {
val enable = !prefsManager.consoleEnabled
prefsManager.consoleEnabled = enable
currentWebView?.let { view ->
if (enable) {
- view.evaluateJavascript(consoleInitScript(), null)
- view.evaluateJavascript(touchHookScript(), null)
+ view.evaluateJavascript(ConsoleScripts.initScript(), null)
+ view.evaluateJavascript(ConsoleScripts.touchHookScript(), null)
} else {
- view.evaluateJavascript(consoleDisableScript(), null)
+ view.evaluateJavascript(ConsoleScripts.disableScript(), null)
}
}
updateMenuConsoleState()
@@ -2264,10 +1899,10 @@ class MainActivity : AppCompatActivity() {
private fun applyConsoleTools(view: WebView) {
if (prefsManager.consoleEnabled) {
- view.evaluateJavascript(consoleInitScript(), null)
- view.evaluateJavascript(touchHookScript(), null)
+ view.evaluateJavascript(ConsoleScripts.initScript(), null)
+ view.evaluateJavascript(ConsoleScripts.touchHookScript(), null)
} else {
- view.evaluateJavascript(consoleDisableScript(), null)
+ view.evaluateJavascript(ConsoleScripts.disableScript(), null)
}
}
@@ -2285,120 +1920,170 @@ class MainActivity : AppCompatActivity() {
mainHandler.post { swipeRefresh.isEnabled = enabled }
}
}
-}
-// ─────────────────────────────────────────────────────────────────────────────
-// محول عرض التبويبات (RecyclerView Adapter)
-// ─────────────────────────────────────────────────────────────────────────────
+ // ─────────────────────────────────────────────────────────────────────────
+ // Input Controller — TV remote · Gamepad · Keyboard · Mouse
+ // ─────────────────────────────────────────────────────────────────────────
-class TabAdapter(
- private val context: Context,
- // FIX #5 — نستقبل الـ executor من الـ Activity بدلاً من إنشاء thread pool خاص
- private val ioExecutor: ExecutorService,
- private val onTabClick: (TabState) -> Unit,
- private val onTabClose: (TabState) -> Unit
-) : RecyclerView.Adapter() {
+ private lateinit var inputController: InputController
+ private lateinit var cursorController: CursorController
+ private var cursorArmed = false
- private var tabs: MutableList = mutableListOf()
- private var activeId: Int = -1
- private val mainHandler = Handler(Looper.getMainLooper())
+ private fun keepCursorAlive() {
+ if (::cursorController.isInitialized && cursorArmed) {
+ cursorController.keepVisible()
+ }
+ }
+
+ /**
+ * Builds the InputController, wiring all browser actions via lambdas.
+ * Called once from onCreate() after initViews().
+ */
+ private fun buildInputController(): InputController {
+ return InputController(object : InputController.Handlers {
+
+ override fun getWebView(): WebView? = currentWebView
- // FIX #6 — DiffUtil بدلاً من notifyDataSetChanged الكارثية
- fun submitUpdate(newTabs: List, newActiveId: Int) {
- val oldTabs = tabs
- val oldActiveId = activeId
- val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
- override fun getOldListSize() = oldTabs.size
- override fun getNewListSize() = newTabs.size
- override fun areItemsTheSame(oldPos: Int, newPos: Int) =
- oldTabs[oldPos].id == newTabs[newPos].id
- override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
- val old = oldTabs[oldPos]; val new = newTabs[newPos]
- // يعيد رسم العنصر فقط إذا تغيّر المحتوى أو حالة النشاط
- return old.title == new.title &&
- old.url == new.url &&
- old.hasThumbnail == new.hasThumbnail &&
- (old.id == oldActiveId) == (new.id == newActiveId)
+ override fun isTopBarVisible() = topBar.visibility == View.VISIBLE
+
+ override fun isTabsOverlayVisible() = tabsOverlay.visibility == View.VISIBLE
+
+ override fun showTopBar() = setTopBarVisible(true)
+
+ override fun hideTopBar() = setTopBarVisible(false)
+
+ override fun focusUrlBar() = showSearchTopBar()
+
+ override fun navigateBack() {
+ currentWebView?.let { if (it.canGoBack()) it.goBack() }
}
- })
- tabs = newTabs.toMutableList()
- activeId = newActiveId
- diff.dispatchUpdatesTo(this) // يُطبّق فقط التغييرات الضرورية
- }
- fun updateFavicon(tabId: Int, favicon: Bitmap) {
- val position = tabs.indexOfFirst { it.id == tabId }
- if (position >= 0) {
- tabs[position].faviconBitmap = favicon
- mainHandler.post { notifyItemChanged(position) }
- }
- }
+ override fun navigateForward() {
+ currentWebView?.let { if (it.canGoForward()) it.goForward() }
+ }
- inner class VH(v: View) : RecyclerView.ViewHolder(v) {
- val title: TextView = v.findViewById(R.id.tabTitle)
- val favicon: ImageView = v.findViewById(R.id.tabFavicon)
- val thumbnail: ImageView = v.findViewById(R.id.tabThumbnail)
- val close: ImageView = v.findViewById(R.id.tabClose)
- }
+ override fun navigateHome() = showHomeOverlay()
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
- val view = LayoutInflater.from(parent.context).inflate(R.layout.item_tab, parent, false)
- return VH(view)
- }
+ override fun openNewTab() = this@MainActivity.openNewTab()
- override fun onBindViewHolder(h: VH, position: Int) {
- val tab = tabs[position]
- val isActive = tab.id == activeId
+ override fun closeCurrentTab() {
+ val tab = currentGroup?.tabs?.find { it.id == activeTabId } ?: return
+ closeTab(tab)
+ }
- h.title.text = tab.title.ifEmpty { "New Tab" }
+ override fun reload() { currentWebView?.reload() }
- if (tab.faviconBitmap != null) {
- h.favicon.setImageBitmap(tab.faviconBitmap)
- h.favicon.imageTintList = null
- } else {
- h.favicon.setImageResource(R.drawable.ic_favicon_fallback)
- h.favicon.imageTintList = android.content.res.ColorStateList.valueOf(
- if (isActive) 0xFF003366.toInt() else 0xFFFFFFFF.toInt()
- )
- }
+ override fun showMenu() = showMenuSheet()
- h.thumbnail.scaleType = ImageView.ScaleType.CENTER_CROP
+ override fun showTabs() = showTabsOverlay()
- if (tab.ramThumbnail != null) {
- h.thumbnail.setImageBitmap(tab.ramThumbnail)
- } else if (isHomeUrl(tab.url)) {
- val homePreview = (context as? MainActivity)?.getHomePreviewBitmap()
- ?: generateHomePreviewBitmap()
- h.thumbnail.setImageBitmap(homePreview)
- } else {
- val file = File(context.cacheDir, "thumb_${tab.id}.webp")
- if (tab.hasThumbnail || file.exists()) {
- // FIX #8 (adapter) — استخدام الـ executor الممرر من الـ Activity
- ioExecutor.execute {
- val bitmap = BitmapFactory.decodeFile(file.absolutePath)
- mainHandler.post {
- // FIX #8 — استخدام bindingAdapterPosition بدلاً من adapterPosition المُهمل
- val currentPos = h.bindingAdapterPosition
- if (currentPos != RecyclerView.NO_POSITION && currentPos == position) {
- if (bitmap != null) h.thumbnail.setImageBitmap(bitmap)
- else h.thumbnail.setImageResource(android.R.color.transparent)
- }
+ override fun nextTab() = this@MainActivity.nextTab()
+
+ override fun prevTab() = this@MainActivity.prevTab()
+
+ override fun toggleConsole() = toggleConsoleForCurrentPage()
+
+ override fun toggleFind() {
+ if (findBar.visibility == View.VISIBLE) {
+ findBar.visibility = View.GONE
+ currentWebView?.clearMatches()
+ hideKeyboard()
+ } else {
+ findBar.visibility = View.VISIBLE
+ currentWebView?.setFindListener { ord, total, _ ->
+ findViewById(R.id.findMatches).text =
+ if (total > 0) "${ord + 1}/$total" else "0/0"
}
+ val fi = findViewById(R.id.findInput)
+ fi.requestFocus()
+ (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
+ .showSoftInput(fi, InputMethodManager.SHOW_IMPLICIT)
}
- } else {
- h.thumbnail.setImageResource(android.R.color.transparent)
}
- }
- h.itemView.background = context.getDrawable(
- if (isActive) R.drawable.tab_card_active else R.drawable.tab_card_bg
- )
- h.title.setTextColor(if (isActive) 0xFF003366.toInt() else 0xFFFFFFFF.toInt())
- h.close.setColorFilter(if (isActive) 0xFF003366.toInt() else 0xFFAAAAAA.toInt())
+ override fun dismissTopOverlay(): Boolean {
+ return when {
+ tabsOverlay.visibility == View.VISIBLE -> {
+ tabsOverlay.visibility = View.GONE
+ keepCursorAlive()
+ true
+ }
+ nativeOverlayContainer.visibility == View.VISIBLE -> {
+ hideNativeOverlays(); true
+ }
+ topBar.visibility == View.VISIBLE -> {
+ setTopBarVisible(false); hideKeyboard(); true
+ }
+ findBar.visibility == View.VISIBLE -> {
+ findBar.visibility = View.GONE
+ currentWebView?.clearMatches()
+ hideKeyboard()
+ keepCursorAlive()
+ true
+ }
+ else -> false
+ }
+ }
+ })
+ }
+
+ /** Cycle to the next tab in the active group (wraps around). */
+ private fun nextTab() {
+ keepCursorAlive()
+ if (::inputController.isInitialized) inputController.stopScrollLoop()
+ val tabs = currentGroup?.tabs ?: return
+ if (tabs.size < 2) return
+ val idx = tabs.indexOfFirst { it.id == activeTabId }
+ switchToTab(tabs[(idx + 1) % tabs.size])
+ }
+
+ /** Cycle to the previous tab in the active group (wraps around). */
+ private fun prevTab() {
+ keepCursorAlive()
+ if (::inputController.isInitialized) inputController.stopScrollLoop()
+ val tabs = currentGroup?.tabs ?: return
+ if (tabs.size < 2) return
+ val idx = tabs.indexOfFirst { it.id == activeTabId }
+ switchToTab(tabs[(idx - 1 + tabs.size) % tabs.size])
+ }
- h.itemView.setOnClickListener { onTabClick(tab) }
- h.close.setOnClickListener { onTabClose(tab) }
+ // ── dispatchKeyEvent ──────────────────────────────────────────────────────
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ // BACK is always handled by onBackPressedDispatcher — never intercept it.
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) return super.dispatchKeyEvent(event)
+
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ if (event.isFromSource(InputDevice.SOURCE_GAMEPAD) || event.isFromSource(InputDevice.SOURCE_JOYSTICK) || event.isFromSource(InputDevice.SOURCE_DPAD)) {
+ cursorArmed = true
+ }
+ // When an EditText (URL bar, find bar) owns focus, only intercept:
+ // • Escape → dismiss overlay
+ // • Ctrl+* → browser shortcuts still work while typing
+ // • F5/F12 → function keys
+ // Everything else (typing, Enter, arrows inside the field) → super.
+ val textFocused = currentFocus is EditText
+ val isSpecialKey = event.isCtrlPressed
+ || event.keyCode == KeyEvent.KEYCODE_ESCAPE
+ || event.keyCode == KeyEvent.KEYCODE_F5
+ || event.keyCode == KeyEvent.KEYCODE_F12
+
+ if (textFocused && !isSpecialKey) return super.dispatchKeyEvent(event)
+
+ if (inputController.onKeyDown(event)) return true
+ }
+
+ return super.dispatchKeyEvent(event)
}
- override fun getItemCount(): Int = tabs.size
+ // ── dispatchGenericMotionEvent ────────────────────────────────────────────
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ if (event.isFromSource(InputDevice.SOURCE_JOYSTICK)) {
+ cursorArmed = true
+ }
+ keepCursorAlive()
+ if (inputController.onGenericMotion(event)) return true
+ return super.dispatchGenericMotionEvent(event)
+ }
}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/SettingsActivity.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/SettingsActivity.kt
new file mode 100644
index 0000000..248868f
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/SettingsActivity.kt
@@ -0,0 +1,271 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+import android.content.Intent
+import android.text.InputType
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.ListView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.SwitchCompat
+import android.os.Bundle
+
+class SettingsActivity : AppCompatActivity() {
+
+ private lateinit var prefsManager: PrefsManager
+
+ private data class EngineOption(val name: String, val url: String, val iconRes: Int?)
+
+ private val engineOptions = listOf(
+ EngineOption("Google", "https://www.google.com/search?q=", R.drawable.ic_engine_google),
+ EngineOption("DuckDuckGo", "https://duckduckgo.com/?q=", R.drawable.ic_engine_duckduckgo),
+ EngineOption("Bing", "https://www.bing.com/search?q=", R.drawable.ic_engine_bing),
+ EngineOption("Brave", "https://search.brave.com/search?q=", R.drawable.ic_engine_brave),
+ EngineOption("Custom", "", null)
+ )
+
+ // ─── lifecycle ────────────────────────────────────────────────────────────
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_settings)
+ prefsManager = PrefsManager(this)
+
+ // back
+ findViewById(R.id.btnBack).setOnClickListener { finish() }
+
+ // search engine
+ updateSearchEngineValue()
+ findViewById(R.id.settingSearchEngine).setOnClickListener { showSearchEnginePicker() }
+
+ // desktop mode
+ val switchDesktop = findViewById(R.id.switchDesktopMode)
+ switchDesktop.isChecked = prefsManager.desktopMode
+ switchDesktop.setOnCheckedChangeListener { _, checked -> prefsManager.desktopMode = checked }
+ findViewById(R.id.settingDesktopMode).setOnClickListener {
+ switchDesktop.isChecked = !switchDesktop.isChecked
+ }
+
+ // custom JS
+ findViewById(R.id.settingCustomJs).setOnClickListener { showCustomJsDialog() }
+
+ // clear data
+ findViewById(R.id.settingClearData).setOnClickListener { showClearDataDialog() }
+
+ // about — opens AboutActivity
+ findViewById(R.id.settingAbout).setOnClickListener {
+ startActivity(Intent(this, AboutActivity::class.java))
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshUpdateBadge()
+ // Silently refresh cache in background; update badge on result
+ UpdateManager.check(this) { _, _ -> refreshUpdateBadge() }
+ }
+
+ // ─── update badge ─────────────────────────────────────────────────────────
+
+ private fun refreshUpdateBadge() {
+ val dot = findViewById(R.id.updateDot) ?: return
+ dot.visibility = if (UpdateManager.isUpdateAvailable(this)) View.VISIBLE else View.GONE
+ }
+
+ // ─── dialogs ─────────────────────────────────────────────────────────────
+
+ private fun showCustomJsDialog() {
+ val input = EditText(this).apply {
+ setText(prefsManager.customJs)
+ setTextColor(0xFFFFFFFF.toInt())
+ setBackgroundColor(0xFF111111.toInt())
+ setPadding(32, 24, 32, 24)
+ hint = "// Your JavaScript here..."
+ setHintTextColor(0xFF444444.toInt())
+ isSingleLine = false
+ minLines = 5
+ }
+ AlertDialog.Builder(this, R.style.DarkDialog)
+ .setTitle("Custom JavaScript")
+ .setView(input)
+ .setPositiveButton("Save") { _, _ ->
+ prefsManager.customJs = input.text.toString()
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun showClearDataDialog() {
+ AlertDialog.Builder(this, R.style.DarkDialog)
+ .setTitle("Clear Browsing Data")
+ .setMessage("This will delete cache, cookies, and history.")
+ .setPositiveButton("Clear") { _, _ ->
+ android.webkit.WebStorage.getInstance().deleteAllData()
+ val cookies = android.webkit.CookieManager.getInstance()
+ cookies.removeAllCookies(null)
+ cookies.flush()
+ prefsManager.clearHistory()
+ getSharedPreferences("ConsoleFlowPrefs", Context.MODE_PRIVATE)
+ .edit()
+ .remove("SAVED_GROUPS")
+ .remove("ACTIVE_GROUP")
+ .remove("ACTIVE_TAB")
+ .remove("NEXT_TAB_ID")
+ .remove("NEXT_GROUP_ID")
+ .apply()
+ cacheDir.listFiles()?.forEach { file ->
+ if (file.name.startsWith("thumb_") && file.name.endsWith(".webp")) file.delete()
+ if (file.name.startsWith("home_preview_") &&
+ (file.name.endsWith(".webp") || file.name.endsWith(".sig"))) file.delete()
+ }
+ Toast.makeText(this, "Data cleared", Toast.LENGTH_SHORT).show()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ // ─── search engine picker ─────────────────────────────────────────────────
+
+ private fun updateSearchEngineValue() {
+ val label = if (prefsManager.searchEngineIsCustom) "Custom"
+ else searchEngineDisplayName(prefsManager.searchEngine)
+ findViewById(R.id.settingSearchEngineValue).text = label
+ }
+
+ private fun showSearchEnginePicker() {
+ val listView = ListView(this).apply {
+ divider = null
+ setPadding(24, 20, 24, 20)
+ clipToPadding = false
+ }
+
+ val adapter = object : BaseAdapter() {
+ override fun getCount() = engineOptions.size
+ override fun getItem(position: Int) = engineOptions[position]
+ override fun getItemId(position: Int) = position.toLong()
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val ctx = parent.context
+ val row = (convertView as? LinearLayout) ?: LinearLayout(ctx).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(24, 22, 24, 22)
+ minimumHeight = (56 * resources.displayMetrics.density).toInt()
+
+ addView(ImageView(ctx).apply {
+ id = 1
+ val size = (24 * resources.displayMetrics.density).toInt()
+ layoutParams = LinearLayout.LayoutParams(size, size)
+ })
+ addView(TextView(ctx).apply {
+ id = 2
+ setTextColor(0xFFFFFFFF.toInt())
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
+ setPadding(16, 0, 0, 0)
+ layoutParams = LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
+ })
+ }
+
+ val icon = row.findViewById(1)
+ val title = row.findViewById(2)
+ val item = getItem(position)
+
+ title.text = item.name
+ if (item.iconRes != null) {
+ icon.visibility = View.VISIBLE
+ icon.setImageResource(item.iconRes)
+ icon.imageTintList = null
+ } else {
+ icon.visibility = View.GONE
+ }
+ return row
+ }
+ }
+
+ listView.adapter = adapter
+
+ val dialog = AlertDialog.Builder(this, R.style.DarkDialog)
+ .setTitle("Search Engine")
+ .setView(listView)
+ .setNegativeButton("Cancel", null)
+ .create()
+
+ listView.setOnItemClickListener { _, _, position, _ ->
+ dialog.dismiss()
+ val selected = engineOptions[position]
+ if (selected.name == "Custom") {
+ showCustomSearchEngineDialog()
+ } else {
+ prefsManager.searchEngine = selected.url
+ prefsManager.searchEngineIsCustom = false
+ updateSearchEngineValue()
+ Toast.makeText(this, "${selected.name} selected", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ dialog.show()
+ }
+
+ private fun showCustomSearchEngineDialog() {
+ val input = EditText(this).apply {
+ val current = prefsManager.searchEngine
+ setText(current.takeIf { prefsManager.searchEngineIsCustom } ?: "https://example.com/search?q=%s")
+ setTextColor(0xFFFFFFFF.toInt())
+ setBackgroundColor(0xFF111111.toInt())
+ setPadding(32, 24, 32, 24)
+ hint = "https://example.com/search?q=%s"
+ setHintTextColor(0xFF444444.toInt())
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
+ isSingleLine = true
+ }
+
+ val wrapper = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(24, 8, 24, 0)
+ addView(TextView(context).apply {
+ text = "Use %s where the search term should go."
+ setTextColor(0xFF888888.toInt())
+ textSize = 13f
+ })
+ addView(input, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 16 })
+ }
+
+ AlertDialog.Builder(this, R.style.DarkDialog)
+ .setTitle("Custom Search Engine")
+ .setView(wrapper)
+ .setPositiveButton("Save") { _, _ ->
+ val value = input.text.toString().trim()
+ if (value.isBlank()) {
+ Toast.makeText(this, "Enter a valid URL", Toast.LENGTH_SHORT).show()
+ return@setPositiveButton
+ }
+ prefsManager.searchEngine = value
+ prefsManager.searchEngineIsCustom = true
+ updateSearchEngineValue()
+ Toast.makeText(this, "Custom search engine saved", Toast.LENGTH_SHORT).show()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ // ─── transition ──────────────────────────────────────────────────────────
+
+ override fun finish() {
+ super.finish()
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/adapters/TabAdapter.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/adapters/TabAdapter.kt
new file mode 100644
index 0000000..68f8adb
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/adapters/TabAdapter.kt
@@ -0,0 +1,129 @@
+package space.karrarnazim.ConsoleFlow
+
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import java.io.File
+import java.util.concurrent.ExecutorService
+
+class TabAdapter(
+ private val context: Context,
+ // FIX #5 — نستقبل الـ executor من الـ Activity بدلاً من إنشاء thread pool خاص
+ private val ioExecutor: ExecutorService,
+ private val onTabClick: (TabState) -> Unit,
+ private val onTabClose: (TabState) -> Unit
+) : RecyclerView.Adapter() {
+
+ private var tabs: MutableList = mutableListOf()
+ private var activeId: Int = -1
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ // FIX #6 — DiffUtil بدلاً من notifyDataSetChanged الكارثية
+ fun submitUpdate(newTabs: List, newActiveId: Int) {
+ val oldTabs = tabs
+ val oldActiveId = activeId
+ val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
+ override fun getOldListSize() = oldTabs.size
+ override fun getNewListSize() = newTabs.size
+ override fun areItemsTheSame(oldPos: Int, newPos: Int) =
+ oldTabs[oldPos].id == newTabs[newPos].id
+ override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
+ val old = oldTabs[oldPos]; val new = newTabs[newPos]
+ // يعيد رسم العنصر فقط إذا تغيّر المحتوى أو حالة النشاط
+ return old.title == new.title &&
+ old.url == new.url &&
+ old.hasThumbnail == new.hasThumbnail &&
+ (old.id == oldActiveId) == (new.id == newActiveId)
+ }
+ })
+ tabs = newTabs.toMutableList()
+ activeId = newActiveId
+ diff.dispatchUpdatesTo(this) // يُطبّق فقط التغييرات الضرورية
+ }
+
+ fun updateFavicon(tabId: Int, favicon: Bitmap) {
+ val position = tabs.indexOfFirst { it.id == tabId }
+ if (position >= 0) {
+ tabs[position].faviconBitmap = favicon
+ mainHandler.post { notifyItemChanged(position) }
+ }
+ }
+
+ inner class VH(v: View) : RecyclerView.ViewHolder(v) {
+ val title: TextView = v.findViewById(R.id.tabTitle)
+ val favicon: ImageView = v.findViewById(R.id.tabFavicon)
+ val thumbnail: ImageView = v.findViewById(R.id.tabThumbnail)
+ val close: ImageView = v.findViewById(R.id.tabClose)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
+ val view = LayoutInflater.from(parent.context).inflate(R.layout.item_tab, parent, false)
+ return VH(view)
+ }
+
+ override fun onBindViewHolder(h: VH, position: Int) {
+ val tab = tabs[position]
+ val isActive = tab.id == activeId
+
+ h.title.text = tab.title.ifEmpty { "New Tab" }
+
+ if (tab.faviconBitmap != null) {
+ h.favicon.setImageBitmap(tab.faviconBitmap)
+ h.favicon.imageTintList = null
+ } else {
+ h.favicon.setImageResource(R.drawable.ic_favicon_fallback)
+ h.favicon.imageTintList = android.content.res.ColorStateList.valueOf(
+ if (isActive) 0xFF003366.toInt() else 0xFFFFFFFF.toInt()
+ )
+ }
+
+ h.thumbnail.scaleType = ImageView.ScaleType.CENTER_CROP
+
+ if (tab.ramThumbnail != null) {
+ h.thumbnail.setImageBitmap(tab.ramThumbnail)
+ } else if (isHomeUrl(tab.url)) {
+ val homePreview = (context as? MainActivity)?.getHomePreviewBitmap()
+ ?: generateHomePreviewBitmap()
+ h.thumbnail.setImageBitmap(homePreview)
+ } else {
+ val file = File(context.cacheDir, "thumb_${tab.id}.webp")
+ if (tab.hasThumbnail || file.exists()) {
+ // FIX #8 (adapter) — استخدام الـ executor الممرر من الـ Activity
+ ioExecutor.execute {
+ val bitmap = BitmapFactory.decodeFile(file.absolutePath)
+ mainHandler.post {
+ // FIX #8 — استخدام bindingAdapterPosition بدلاً من adapterPosition المُهمل
+ val currentPos = h.bindingAdapterPosition
+ if (currentPos != RecyclerView.NO_POSITION && currentPos == position) {
+ if (bitmap != null) h.thumbnail.setImageBitmap(bitmap)
+ else h.thumbnail.setImageResource(android.R.color.transparent)
+ }
+ }
+ }
+ } else {
+ h.thumbnail.setImageResource(android.R.color.transparent)
+ }
+ }
+
+ h.itemView.background = context.getDrawable(
+ if (isActive) R.drawable.tab_card_active else R.drawable.tab_card_bg
+ )
+ h.title.setTextColor(if (isActive) 0xFF003366.toInt() else 0xFFFFFFFF.toInt())
+ h.close.setColorFilter(if (isActive) 0xFF003366.toInt() else 0xFFAAAAAA.toInt())
+
+ h.itemView.setOnClickListener { onTabClick(tab) }
+ h.close.setOnClickListener { onTabClose(tab) }
+ }
+
+ override fun getItemCount(): Int = tabs.size
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/dialogs/BrowserDialogHelpers.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/dialogs/BrowserDialogHelpers.kt
new file mode 100644
index 0000000..99f2540
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/ui/dialogs/BrowserDialogHelpers.kt
@@ -0,0 +1,192 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.content.Context
+import android.graphics.Color
+import android.view.Gravity
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import android.widget.Toast
+import com.google.android.material.bottomsheet.BottomSheetDialog
+
+object BrowserDialogHelpers {
+ fun showModernPopup(
+ context: Context,
+ title: String,
+ items: List,
+ onSelect: (Int) -> Unit
+ ) {
+ val sheet = BottomSheetDialog(context, R.style.AppBottomSheetDialogTheme)
+
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ try { setBackgroundResource(R.drawable.bg_bottom_sheet) }
+ catch (_: Exception) { setBackgroundColor(Color.parseColor("#2C2C2C")) }
+ setPadding(0, 32, 0, 32)
+ }
+
+ val handle = View(context).apply {
+ val dp = context.resources.displayMetrics.density
+ layoutParams = LinearLayout.LayoutParams(
+ (40 * dp).toInt(), (4 * dp).toInt()
+ ).apply {
+ gravity = Gravity.CENTER_HORIZONTAL
+ bottomMargin = (16 * dp).toInt()
+ topMargin = (4 * dp).toInt()
+ }
+ setBackgroundColor(Color.parseColor("#444444"))
+ }
+
+ val titleView = TextView(context).apply {
+ text = title
+ textSize = 18f
+ setTextColor(Color.WHITE)
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(0, 0, 0, 24)
+ }
+
+ container.addView(handle)
+ container.addView(titleView)
+
+ items.forEachIndexed { index, text ->
+ val item = TextView(context).apply {
+ this.text = text
+ setTextColor(Color.WHITE)
+ textSize = 16f
+ setPadding(48, 36, 48, 36)
+ setOnClickListener {
+ sheet.dismiss()
+ onSelect(index)
+ }
+ }
+ container.addView(item)
+ }
+
+ sheet.setContentView(container)
+ sheet.show()
+ }
+
+ fun showListWithFavicons(
+ context: Context,
+ title: String,
+ items: List>,
+ loadFavicon: (String, ImageView) -> Unit,
+ onSelect: (Int) -> Unit
+ ) {
+ val sheet = BottomSheetDialog(context, R.style.AppBottomSheetDialogTheme)
+ val dp = context.resources.displayMetrics.density
+ val scrollView = ScrollView(context)
+
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ try { setBackgroundResource(R.drawable.bg_bottom_sheet) }
+ catch (_: Exception) { setBackgroundColor(Color.parseColor("#2C2C2C")) }
+ setPadding(0, 32, 0, 32)
+ }
+ scrollView.addView(container)
+
+ val handle = View(context).apply {
+ layoutParams = LinearLayout.LayoutParams(
+ (40 * dp).toInt(), (4 * dp).toInt()
+ ).apply {
+ gravity = Gravity.CENTER_HORIZONTAL
+ bottomMargin = (16 * dp).toInt()
+ topMargin = (4 * dp).toInt()
+ }
+ setBackgroundColor(Color.parseColor("#444444"))
+ }
+
+ val titleView = TextView(context).apply {
+ text = title
+ textSize = 18f
+ setTextColor(Color.WHITE)
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(0, 0, 0, (24 * dp).toInt())
+ }
+
+ container.addView(handle)
+ container.addView(titleView)
+
+ items.forEachIndexed { index, item ->
+ val row = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), (16 * dp).toInt())
+ gravity = Gravity.CENTER_VERTICAL
+ }
+
+ val icon = ImageView(context).apply {
+ layoutParams = LinearLayout.LayoutParams((24 * dp).toInt(), (24 * dp).toInt()).apply {
+ marginEnd = (12 * dp).toInt()
+ }
+ setImageResource(android.R.drawable.ic_menu_info_details)
+ }
+
+ loadFavicon(item.second, icon)
+
+ val textContainer = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
+ }
+
+ val titleText = TextView(context).apply {
+ text = item.first
+ textSize = 16f
+ setTextColor(Color.WHITE)
+ }
+
+ val subText = TextView(context).apply {
+ text = item.second
+ textSize = 12f
+ setTextColor(Color.LTGRAY)
+ }
+
+ textContainer.addView(titleText)
+ textContainer.addView(subText)
+
+ row.addView(icon)
+ row.addView(textContainer)
+ row.setOnClickListener {
+ sheet.dismiss()
+ onSelect(index)
+ }
+ container.addView(row)
+ }
+
+ sheet.setContentView(scrollView)
+ sheet.show()
+ }
+
+ fun showBookmarksDialog(
+ context: Context,
+ getBookmarks: () -> List>,
+ openUrl: (String) -> Unit,
+ loadFavicon: (String, ImageView) -> Unit
+ ) {
+ val bookmarks = getBookmarks()
+ if (bookmarks.isEmpty()) {
+ Toast.makeText(context, "No bookmarks", Toast.LENGTH_SHORT).show()
+ return
+ }
+ showListWithFavicons(context, "Bookmarks", bookmarks, loadFavicon) { index ->
+ openUrl(bookmarks[index].second)
+ }
+ }
+
+ fun showHistoryDialog(
+ context: Context,
+ getHistory: () -> List>,
+ openUrl: (String) -> Unit,
+ loadFavicon: (String, ImageView) -> Unit
+ ) {
+ val history = getHistory()
+ if (history.isEmpty()) {
+ Toast.makeText(context, "No history", Toast.LENGTH_SHORT).show()
+ return
+ }
+ showListWithFavicons(context, "History", history, loadFavicon) { index ->
+ openUrl(history[index].second)
+ }
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserChromeClient.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserChromeClient.kt
new file mode 100644
index 0000000..527b8ce
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserChromeClient.kt
@@ -0,0 +1,38 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.graphics.Bitmap
+import android.view.View
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+
+class BrowserChromeClient(
+ private val tabId: Int,
+ private val onProgressChangedUi: (Int, Int) -> Unit,
+ private val onReceivedIconUi: (Int, Bitmap) -> Unit,
+ private val onShowCustomViewUi: (View?, WebChromeClient.CustomViewCallback?) -> Unit,
+ private val onHideCustomViewUi: () -> Unit,
+ private val onPermissionRequestUi: (PermissionRequest?) -> Unit
+) : WebChromeClient() {
+ override fun onProgressChanged(view: WebView, newProgress: Int) {
+ onProgressChangedUi(tabId, newProgress)
+ }
+
+ override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
+ super.onReceivedIcon(view, icon)
+ icon ?: return
+ onReceivedIconUi(tabId, icon)
+ }
+
+ override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
+ onShowCustomViewUi(view, callback)
+ }
+
+ override fun onHideCustomView() {
+ onHideCustomViewUi()
+ }
+
+ override fun onPermissionRequest(request: PermissionRequest?) {
+ onPermissionRequestUi(request)
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserWebViewClient.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserWebViewClient.kt
new file mode 100644
index 0000000..699b793
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserWebViewClient.kt
@@ -0,0 +1,133 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.app.Activity
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.webkit.CookieManager
+import android.net.http.SslError
+import android.webkit.SslErrorHandler
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.appcompat.app.AlertDialog
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.ByteArrayInputStream
+
+class BrowserWebViewClient(
+ private val activity: Activity,
+ private val tabId: Int,
+ private val prefsManager: PrefsManager,
+ private val okClient: OkHttpClient,
+ private val currentWebViewProvider: () -> WebView?,
+ private val isHomeUrl: (String?) -> Boolean,
+ private val isLocalhostHost: (String?) -> Boolean,
+ private val noInterceptDomains: List,
+ private val onPageStartedUi: (Int, WebView, String?) -> Unit,
+ private val onPageFinishedUi: (Int, WebView, String?) -> Unit,
+ private val onReceivedErrorUi: (String?) -> Unit,
+ private val onApplyConsoleTools: (WebView) -> Unit
+) : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
+ val url = req.url.toString()
+ if (url.startsWith("http") || url.startsWith("file:")) return false
+ return try {
+ activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ true
+ } catch (_: Exception) {
+ true
+ }
+ }
+
+ override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
+ if (view == currentWebViewProvider()) {
+ onPageStartedUi(tabId, view, url)
+ }
+ }
+
+ override fun onPageFinished(view: WebView, url: String?) {
+ onPageFinishedUi(tabId, view, url)
+ onApplyConsoleTools(view)
+ }
+
+ override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
+ val url = request.url.toString()
+
+ if (url == "https://eruda.local/eruda.js") {
+ return try {
+ WebResourceResponse("application/javascript", "utf-8", activity.assets.open("eruda.js"))
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ if (prefsManager.getBoolean("disable_intercept", false)) return null
+
+ val host = request.url.host ?: ""
+ if (isLocalhostHost(host)) return null
+ if (noInterceptDomains.any { host == it || host.endsWith(".$it") }) return null
+
+ if (request.isForMainFrame && request.method == "GET" && url.startsWith("http")) {
+ try {
+ val ua = WebViewSettingsHelper.getUserAgentString(activity, prefsManager.desktopMode)
+ val reqBuilder = Request.Builder().url(url)
+ request.requestHeaders.forEach { (k, v) ->
+ if (k.lowercase() != "user-agent") reqBuilder.addHeader(k, v)
+ }
+ reqBuilder.header("User-Agent", ua)
+ val cookie = CookieManager.getInstance().getCookie(url)
+ if (!cookie.isNullOrEmpty()) reqBuilder.header("Cookie", cookie)
+
+ okClient.newCall(reqBuilder.build()).execute().use { response ->
+ val contentType = response.header("Content-Type", "") ?: ""
+ if (contentType.contains("text/html")) {
+ var html = response.body?.string() ?: ""
+
+ html = html.replace(
+ Regex("""]+http-equiv=[\"']Content-Security-Policy[\"'][^>]*>""", RegexOption.IGNORE_CASE),
+ ""
+ )
+ val injectedScripts = UserScriptsManager.buildInjectedScripts(prefsManager.consoleEnabled, prefsManager.customJs)
+ html = html.replaceFirst("", "$injectedScripts", ignoreCase = true)
+ val hdrs = response.headers.toMultimap().mapValues { it.value.firstOrNull().orEmpty() }.toMutableMap()
+ hdrs.remove("Content-Security-Policy")
+ hdrs.remove("content-security-policy")
+
+ return WebResourceResponse(
+ "text/html",
+ "utf-8",
+ response.code,
+ "OK",
+ hdrs,
+ ByteArrayInputStream(html.toByteArray(Charsets.UTF_8))
+ )
+ }
+ }
+ } catch (_: Exception) {
+ return null
+ }
+ }
+ return super.shouldInterceptRequest(view, request)
+ }
+
+ override fun onReceivedError(view: WebView, req: WebResourceRequest, err: WebResourceError) {
+ if (req.isForMainFrame) {
+ activity.runOnUiThread { onReceivedErrorUi(req.url.toString()) }
+ }
+ }
+
+ override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
+ AlertDialog.Builder(activity, R.style.DarkDialog)
+ .setTitle("SSL Certificate Error")
+ .setMessage("The site's security certificate is not trusted. Continue anyway?")
+ .setPositiveButton("Continue") { _, _ -> handler.proceed() }
+ .setNegativeButton("Go Back") { _, _ -> handler.cancel() }
+ .show()
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserWebViewFactory.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserWebViewFactory.kt
new file mode 100644
index 0000000..bc35a29
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/BrowserWebViewFactory.kt
@@ -0,0 +1,140 @@
+package space.karrarnazim.ConsoleFlow
+
+import android.app.Activity
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.view.View
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.widget.Toast
+import okhttp3.OkHttpClient
+
+class BrowserWebViewFactory(
+ private val activity: Activity,
+ private val prefsManager: PrefsManager,
+ private val okClient: OkHttpClient,
+ private val currentWebViewProvider: () -> WebView?,
+ private val isHomeUrl: (String?) -> Boolean,
+ private val isLocalhostHost: (String?) -> Boolean,
+ private val noInterceptDomains: List,
+ private val bookmarkRepository: BookmarkRepository,
+ private val onOpenNewTab: (String) -> Unit,
+ private val onMarkHomeOverlayDirty: () -> Unit,
+ private val onInvalidateHomePreviewCache: () -> Unit,
+ private val onNavigate: (String) -> Unit,
+ private val onSetSwipeRefresh: (Boolean) -> Unit,
+ private val onPageStartedUi: (Int, WebView, String?) -> Unit,
+ private val onProgressChangedUi: (Int, Int) -> Unit,
+ private val onShowCustomViewUi: (View?, WebChromeClient.CustomViewCallback?) -> Unit,
+ private val onHideCustomViewUi: () -> Unit,
+ private val onPermissionRequestUi: (PermissionRequest?) -> Unit,
+ private val onPageFinishedUi: (Int, WebView, String?) -> Unit,
+ private val onReceivedIconUi: (Int, Bitmap) -> Unit,
+ private val onReceivedErrorUi: (String?) -> Unit,
+ private val onApplyConsoleTools: (WebView) -> Unit,
+ private val onDownloadStart: (url: String, userAgent: String, contentDisposition: String, mimeType: String, contentLength: Long) -> Unit
+) {
+
+ @Suppress("SetJavaScriptEnabled")
+ fun create(tabId: Int): WebView {
+ val wv = WebView(activity)
+ wv.layoutParams = android.widget.FrameLayout.LayoutParams(
+ android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
+ android.widget.FrameLayout.LayoutParams.MATCH_PARENT
+ )
+
+ with(wv.settings) {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ databaseEnabled = true
+ setSupportZoom(true)
+ builtInZoomControls = true
+ displayZoomControls = false
+ mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+ mediaPlaybackRequiresUserGesture = false
+ cacheMode = WebSettings.LOAD_DEFAULT
+ // Enable spatial navigation so D-pad/arrow keys move focus between
+ // links and form controls inside the page (TV remote / keyboard use)
+ @Suppress("DEPRECATION")
+ setNeedInitialFocus(true)
+ }
+
+ // WebView must be focusable so TV-remote / keyboard events reach it
+ wv.isFocusable = true
+ wv.isFocusableInTouchMode = true
+
+ WebViewSettingsHelper.applyUserAgentToWebView(activity, wv, prefsManager.desktopMode)
+ wv.addJavascriptInterface(JsBridge(onNavigate, onSetSwipeRefresh), "Android")
+
+ wv.setOnCreateContextMenuListener { _, _, _ ->
+ val result = wv.hitTestResult
+ if (result.type == WebView.HitTestResult.SRC_ANCHOR_TYPE ||
+ result.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
+ val url = result.extra ?: return@setOnCreateContextMenuListener
+ BrowserDialogHelpers.showModernPopup(activity, url, listOf("Open in New Tab", "Copy Link", "Bookmark Link", "Share")) { index ->
+ when (index) {
+ 0 -> {
+ onOpenNewTab(url)
+ Toast.makeText(activity, "Opened in new tab", Toast.LENGTH_SHORT).show()
+ }
+ 1 -> {
+ (activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
+ .setPrimaryClip(ClipData.newPlainText("URL", url))
+ Toast.makeText(activity, "Copied", Toast.LENGTH_SHORT).show()
+ }
+ 2 -> {
+ bookmarkRepository.toggleBookmark("Bookmark", url)
+ onMarkHomeOverlayDirty()
+ onInvalidateHomePreviewCache()
+ Toast.makeText(activity, "Bookmarked", Toast.LENGTH_SHORT).show()
+ }
+ 3 -> activity.startActivity(Intent.createChooser(
+ Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, url)
+ },
+ "Share"
+ ))
+ }
+ }
+ }
+ }
+
+ wv.webViewClient = BrowserWebViewClient(
+ activity = activity,
+ tabId = tabId,
+ prefsManager = prefsManager,
+ okClient = okClient,
+ currentWebViewProvider = currentWebViewProvider,
+ isHomeUrl = isHomeUrl,
+ isLocalhostHost = isLocalhostHost,
+ noInterceptDomains = noInterceptDomains,
+ onPageStartedUi = onPageStartedUi,
+ onPageFinishedUi = onPageFinishedUi,
+ onReceivedErrorUi = onReceivedErrorUi,
+ onApplyConsoleTools = onApplyConsoleTools
+ )
+
+ wv.webChromeClient = BrowserChromeClient(
+ tabId = tabId,
+ onProgressChangedUi = onProgressChangedUi,
+ onReceivedIconUi = onReceivedIconUi,
+ onShowCustomViewUi = onShowCustomViewUi,
+ onHideCustomViewUi = onHideCustomViewUi,
+ onPermissionRequestUi = onPermissionRequestUi
+ )
+
+ // ── Download listener ────────────────────────────────────────────────
+ // Called by WebView when it encounters a URL it can't render itself.
+ wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
+ onDownloadStart(url, userAgent, contentDisposition, mimeType, contentLength)
+ }
+
+ return wv
+ }
+}
diff --git a/app/src/main/java/space/karrarnazim/ConsoleFlow/web/JsBridge.kt b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/JsBridge.kt
new file mode 100644
index 0000000..4cfb3db
--- /dev/null
+++ b/app/src/main/java/space/karrarnazim/ConsoleFlow/web/JsBridge.kt
@@ -0,0 +1,16 @@
+package space.karrarnazim.ConsoleFlow
+
+class JsBridge(
+ private val navigateCallback: (String) -> Unit,
+ private val setSwipeRefreshCallback: (Boolean) -> Unit
+) {
+ @android.webkit.JavascriptInterface
+ fun navigate(input: String) {
+ navigateCallback(input)
+ }
+
+ @android.webkit.JavascriptInterface
+ fun setSwipeRefresh(enabled: Boolean) {
+ setSwipeRefreshCallback(enabled)
+ }
+}
diff --git a/app/src/main/res/drawable-xhdpi/tv_banner.png b/app/src/main/res/drawable-xhdpi/tv_banner.png
new file mode 100644
index 0000000..3fa8cd9
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/tv_banner.png differ
diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml
new file mode 100644
index 0000000..35116cc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_download.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_splashscreen.png b/app/src/main/res/drawable/ic_splashscreen.png
index 1afddc6..3ae1ad0 100644
Binary files a/app/src/main/res/drawable/ic_splashscreen.png and b/app/src/main/res/drawable/ic_splashscreen.png differ
diff --git a/app/src/main/res/drawable/logo_consoleflow.xml b/app/src/main/res/drawable/logo_consoleflow.xml
new file mode 100644
index 0000000..051169b
--- /dev/null
+++ b/app/src/main/res/drawable/logo_consoleflow.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/update_dot.xml b/app/src/main/res/drawable/update_dot.xml
new file mode 100644
index 0000000..4afc783
--- /dev/null
+++ b/app/src/main/res/drawable/update_dot.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml
new file mode 100644
index 0000000..71eb6ee
--- /dev/null
+++ b/app/src/main/res/layout/activity_about.xml
@@ -0,0 +1,488 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 373de67..55f4455 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -11,10 +11,10 @@
-
+
-
+
@@ -75,22 +75,22 @@
-
+
-
+
-
+
-
+
@@ -98,7 +98,7 @@
-
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index 44ee3a4..cbc9b4e 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -5,6 +5,7 @@
android:orientation="vertical"
android:background="#000000">
+
+ android:layout_marginStart="10dp"
+ android:orientation="vertical">
@@ -51,6 +52,7 @@
android:layout_height="1dp"
android:background="#1A1A1A" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -134,6 +82,7 @@
android:background="@drawable/bg_menu_item"
android:orientation="vertical">
+
+ android:tint="#888888" />
+ android:tint="#333333" />
+ android:background="#1A1A1A" />
+
+ android:tint="#888888" />
+
@@ -229,6 +180,7 @@
android:background="@drawable/bg_menu_item"
android:orientation="vertical">
+
+ android:tint="#888888" />
+ android:tint="#333333" />
+
@@ -291,6 +244,7 @@
android:background="@drawable/bg_menu_item"
android:orientation="vertical">
+
+ android:tint="#CC4444" />
+
@@ -348,57 +303,7 @@
android:orientation="vertical">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:tint="#888888" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:text="About ConsoleFlow"
+ android:textColor="#FFFFFF"
+ android:textSize="15sp" />
-
+
-
-
-
-
-
+ android:layout_marginEnd="8dp"
+ android:background="@drawable/update_dot"
+ android:paddingHorizontal="7dp"
+ android:paddingVertical="2dp"
+ android:text="UPDATE"
+ android:textColor="#000000"
+ android:textSize="9sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.04"
+ android:visibility="gone" />
+ android:tint="#333333" />
+ android:layout_height="32dp" />
+
diff --git a/app/src/main/res/layout/layout_main_menu.xml b/app/src/main/res/layout/layout_main_menu.xml
index d07f723..f5df3e8 100644
--- a/app/src/main/res/layout/layout_main_menu.xml
+++ b/app/src/main/res/layout/layout_main_menu.xml
@@ -4,16 +4,54 @@
android:layout_height="wrap_content"
android:background="@drawable/bg_bottom_sheet"
android:orientation="vertical"
- android:padding="16dp">
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ android:paddingTop="12dp"
+ android:paddingBottom="16dp">
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_width="20dp"
+ android:layout_height="20dp"
+ android:src="@drawable/ic_download"
+ android:tint="#4A90D9" />
+ android:textSize="14sp" />
+
+
+
-
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 9873fb1..05cfeec 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 3888c94..c460f07 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
index 3888c94..c460f07 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 969e91d..9f3f287 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index 2858444..0d5cbfb 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
index 2858444..0d5cbfb 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 869dd21..3f2292d 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index b26f1d8..1ea0e37 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
index b26f1d8..1ea0e37 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 377dca9..6cc4ce6 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index 00449c6..00a56d1 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
index 00449c6..00a56d1 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 5ee1eef..d5b0a1a 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index 147c670..7609247 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
index 147c670..7609247 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/builddata.json b/builddata.json
index d72fc94..bb1300c 100644
--- a/builddata.json
+++ b/builddata.json
@@ -1,7 +1,7 @@
{
- "release_name": "v2.1.8 ConsoleFlow",
- "release_body": "fix status bar padding in Android 15. fix taps crash problem. adding logs page",
- "apk_name": "ConsoleFlow_v2.1.8.apk",
+ "release_name": "v2.2.8 ConsoleFlow",
+ "release_body": "## What is new\n\n- Added a **new dedicated download management system** with its own page.\n- Added **full support** for controllers, keyboard, mouse, and TV remotes.\n- Fixed some issues and made other improvements.\n- New app logo.\n",
+ "apk_name": "ConsoleFlow_v2.2.8.apk",
"is_prerelease": false,
- "tag": "v2.1.8"
-}
\ No newline at end of file
+ "tag": "v2.2.8"
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt
index c6c4dd3..cc48525 100644
--- a/fastlane/metadata/android/en-US/changelogs/3.txt
+++ b/fastlane/metadata/android/en-US/changelogs/3.txt
@@ -1 +1 @@
-Initial release of ConsoleFlow — developer browser with automatic Eruda console injection on every page.
+Refactored the browser codebase into separate ui, web, tabs, storage, console, and cache layers.
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png
index fc6b316..0177a91 100644
Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index 4ba1d04..6dd1d15 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/gradlew b/gradlew
old mode 100755
new mode 100644
diff --git a/metadata/space.karrarnazim.ConsoleFlow.yml b/metadata/space.karrarnazim.ConsoleFlow.yml
deleted file mode 100644
index ee85840..0000000
--- a/metadata/space.karrarnazim.ConsoleFlow.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-Categories:
- - Browser
-License: GPL-3.0-only
-WebSite: https://consoleflow.karrarnazim.space
-SourceCode: https://github.com/SANDRO00O/ConsoleFlow-mobile
-IssueTracker: https://github.com/SANDRO00O/ConsoleFlow-mobile/issues
-
-AutoName: ConsoleFlow
-
-RepoType: git
-Repo: https://github.com/SANDRO00O/ConsoleFlow-mobile
-
-Builds:
- - versionName: 2.1.8
- versionCode: 4
- commit: 06efd30b8df7c93fca485555c28e3234574c9c47
- subdir: app
- gradle:
- - yes
-
-AutoUpdateMode: Version
-UpdateCheckMode: Tags
-CurrentVersion: 2.1.8
-CurrentVersionCode: 4
diff --git a/settings.gradle b/settings.gradle
index 8640ca9..cf161b3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -2,14 +2,14 @@ pluginManagement {
repositories {
gradlePluginPortal()
google()
- maven { url "https://repo1.maven.org/maven2/" }
+ mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
- maven { url "https://repo1.maven.org/maven2/" }
+ mavenCentral()
}
}
rootProject.name = "ConsoleFlow"