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.** [![Open Source](https://img.shields.io/badge/Open%20Source-Yes!-brightgreen?style=for-the-badge)](#) -[![GitHub stars](https://img.shields.io/github/stars/SANDRO00O/ConsoleFlow-mobile?style=for-the-badge&logo=github)](https://github.com/SANDRO00O/ConsoleFlow-mobile/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/SANDRO00O/ConsoleFlow-mobile?style=for-the-badge&logo=github)](https://github.com/SANDRO00O/ConsoleFlow-mobile/network/members) [![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](https://github.com/SANDRO00O/ConsoleFlow-mobile/blob/main/LICENSE) +[![Downloads](https://img.shields.io/github/downloads/SANDRO00O/ConsoleFlow-mobile/total?logo=github)](https://github.com/SANDRO00O/ConsoleFlow-mobile/releases) +[![Downloads last month](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgithub.com%2Fkitswas%2Ffdroid-metrics-dashboard%2Fraw%2Frefs%2Fheads%2Fmain%2Fprocessed%2Fmonthly%2Fspace.karrarnazim.ConsoleFlow.json&query=%24.total_downloads&logo=fdroid&label=Downloads%20last%20month)](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"