Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
+10
View File
@@ -26,6 +26,9 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
// flutter_local_notifications uses java.time APIs that need desugaring
// to run on our minSdk of 24.
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@@ -77,3 +80,10 @@ android {
flutter {
source = "../.."
}
dependencies {
// Required by isCoreLibraryDesugaringEnabled above.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// NotificationCompat / NotificationManagerCompat used by MainActivity.
implementation("androidx.core:core-ktx:1.15.0")
}
@@ -13,7 +13,13 @@
<!-- Security (Biometrics) -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<!-- Notifications. POST_NOTIFICATIONS is mandatory from Android 13 (API 33);
without it the runtime prompt never appears and no notification is ever
shown. VIBRATE is used by the high-importance channel. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<application
android:label="مُصادَق"
android:name="${applicationName}"
@@ -41,6 +47,31 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Home screen widget. The layout and provider-info XML already existed
but the receiver was never registered, so Android never offered the
widget at all. -->
<receiver
android:name=".MusadaqWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_musadaq_info" />
</receiver>
<!-- FCM: the channel a notification lands on must already exist on
Android 8+, otherwise the system silently drops it. The server sends
channel_id "high_importance_channel"; MainActivity creates it, and
this meta-data is the fallback for payloads without a channel. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/launcher_icon" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -1,5 +1,233 @@
package com.musadaq.app
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterFragmentActivity()
/**
* Hosts the Flutter engine plus the two native bridges the app expects:
*
* - `com.musadaq.liveactivity` — Android's answer to an iOS Live Activity.
* On Android 16 (API 36) an ongoing progress notification can be promoted to
* a "Live Update", which surfaces it in the status bar chip much like the
* Dynamic Island. On older releases the same notification is shown as a plain
* ongoing progress notification, which is the best the platform offers.
*
* - `com.musadaq.widget` — data feed for the home screen widget. HomeWidgetService
* has always called this channel; nothing implemented it, so every call
* silently threw MissingPluginException and the widget stayed empty.
*/
class MainActivity : FlutterFragmentActivity() {
companion object {
/** Must match PushNotificationService.channelId and the manifest meta-data. */
const val PUSH_CHANNEL_ID = "high_importance_channel"
/** Separate low-importance channel: progress must not buzz on every image. */
const val PROGRESS_CHANNEL_ID = "upload_progress_channel"
const val PROGRESS_NOTIFICATION_ID = 4711
private const val LIVE_ACTIVITY_CHANNEL = "com.musadaq.liveactivity"
private const val WIDGET_CHANNEL = "com.musadaq.widget"
}
private var progressActive = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
createNotificationChannels()
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
LIVE_ACTIVITY_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
val company = call.argument<String>("companyName") ?: ""
val total = call.argument<Int>("total") ?: 1
showProgress(
company = company,
current = 0,
total = total,
failed = 0,
isDone = false,
statusText = "جارٍ الرفع"
)
// Android has no per-activity push token; the FCM device token
// already reaches this notification via the data message.
result.success(null)
}
"update" -> {
showProgress(
company = call.argument<String>("companyName") ?: currentCompany,
current = call.argument<Int>("current") ?: 0,
total = call.argument<Int>("total") ?: 1,
failed = call.argument<Int>("failed") ?: 0,
isDone = call.argument<Boolean>("isDone") ?: false,
statusText = call.argument<String>("statusText") ?: ""
)
result.success(null)
}
"end" -> {
clearProgress()
result.success(null)
}
"isSupported" -> result.success(
NotificationManagerCompat.from(this).areNotificationsEnabled()
)
else -> result.notImplemented()
}
}
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
WIDGET_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"updateWidget" -> {
@Suppress("UNCHECKED_CAST")
val data = call.arguments as? Map<String, Any?> ?: emptyMap()
MusadaqWidgetProvider.saveData(this, data)
MusadaqWidgetProvider.refreshAll(this)
result.success(true)
}
else -> result.notImplemented()
}
}
}
private var currentCompany: String = ""
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Without this channel existing, Android 8+ DISCARDS any notification the
// server addresses to "high_importance_channel" — which is what the
// backend sends. That silently killed every push notification.
manager.createNotificationChannel(
NotificationChannel(
PUSH_CHANNEL_ID,
"إشعارات مُصادَق",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "إشعارات معالجة الفواتير والتنبيهات المهمة"
enableVibration(true)
}
)
manager.createNotificationChannel(
NotificationChannel(
PROGRESS_CHANNEL_ID,
"تقدّم رفع الفواتير",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "شريط تقدّم رفع ومعالجة الفواتير"
setShowBadge(false)
}
)
}
private fun hasNotificationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
private fun showProgress(
company: String,
current: Int,
total: Int,
failed: Int,
isDone: Boolean,
statusText: String
) {
if (!hasNotificationPermission()) return
currentCompany = company
val safeTotal = if (total < 1) 1 else total
val done = current + failed
val title = when {
!isDone -> if (statusText.isNotEmpty()) "مُصادَق — $statusText" else "مُصادَق — جارٍ الرفع"
failed > 0 && current > 0 -> "اكتمل مع أخطاء"
failed > 0 -> "فشلت المعالجة"
else -> "تم بنجاح"
}
val text = buildString {
append("$current / $safeTotal فاتورة")
if (failed > 0) append(" — $failed فاشلة")
if (company.isNotEmpty()) append(" — $company")
}
val builder = NotificationCompat.Builder(this, PROGRESS_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle(title)
.setContentText(text)
.setOnlyAlertOnce(true)
.setOngoing(!isDone)
.setAutoCancel(isDone)
if (isDone) {
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_sys_upload_done)
} else {
builder.setProgress(safeTotal, done, false)
// Android 16+ can promote an ongoing notification to a Live Update
// (status-bar chip). Called reflectively so the project still builds
// against an older compileSdk.
promoteToLiveUpdate(builder)
}
try {
NotificationManagerCompat.from(this)
.notify(PROGRESS_NOTIFICATION_ID, builder.build())
progressActive = true
} catch (e: SecurityException) {
// Permission revoked between the check and the notify.
}
}
/**
* Best-effort call to NotificationCompat.Builder#setRequestPromotedOngoing,
* which only exists on newer androidx/API levels.
*/
private fun promoteToLiveUpdate(builder: NotificationCompat.Builder) {
if (Build.VERSION.SDK_INT < 36) return
try {
val method = builder.javaClass.getMethod(
"setRequestPromotedOngoing",
Boolean::class.javaPrimitiveType
)
method.invoke(builder, true)
} catch (e: NoSuchMethodException) {
// Older androidx.core — plain ongoing notification is the fallback.
} catch (e: Exception) {
// Never let a cosmetic upgrade break the upload.
}
}
private fun clearProgress() {
if (!progressActive) return
progressActive = false
NotificationManagerCompat.from(this).cancel(PROGRESS_NOTIFICATION_ID)
}
}
@@ -0,0 +1,107 @@
package com.musadaq.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
/**
* Home screen widget showing invoice counts and remaining quota.
*
* res/layout/widget_musadaq.xml and res/xml/widget_musadaq_info.xml already
* existed, but no provider class or <receiver> was ever declared — so Android
* never offered the widget and HomeWidgetService's `updateWidget` channel call
* had nothing on the other end.
*
* Values are cached in SharedPreferences because a widget can be redrawn by the
* OS at any time, including when the Flutter engine is not running.
*/
class MusadaqWidgetProvider : AppWidgetProvider() {
companion object {
private const val PREFS = "musadaq_widget_prefs"
private const val KEY_TOTAL = "total_invoices"
private const val KEY_PENDING = "pending_invoices"
private const val KEY_QUOTA_USED = "quota_used"
private const val KEY_QUOTA_LIMIT = "quota_limit"
private const val KEY_LAST_UPDATE = "last_update"
/** Persist the latest stats pushed down from Dart. */
fun saveData(context: Context, data: Map<String, Any?>) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
putInt(KEY_TOTAL, (data[KEY_TOTAL] as? Number)?.toInt() ?: 0)
putInt(KEY_PENDING, (data[KEY_PENDING] as? Number)?.toInt() ?: 0)
putInt(KEY_QUOTA_USED, (data[KEY_QUOTA_USED] as? Number)?.toInt() ?: 0)
putInt(KEY_QUOTA_LIMIT, (data[KEY_QUOTA_LIMIT] as? Number)?.toInt() ?: 0)
putString(KEY_LAST_UPDATE, data[KEY_LAST_UPDATE] as? String ?: "")
apply()
}
}
/** Ask the OS to redraw every placed instance of this widget. */
fun refreshAll(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
ComponentName(context, MusadaqWidgetProvider::class.java)
)
if (ids.isEmpty()) return
for (id in ids) {
manager.updateAppWidget(id, buildViews(context))
}
}
private fun buildViews(context: Context): RemoteViews {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val views = RemoteViews(context.packageName, R.layout.widget_musadaq)
views.setTextViewText(
R.id.widget_total_invoices,
prefs.getInt(KEY_TOTAL, 0).toString()
)
views.setTextViewText(
R.id.widget_pending,
prefs.getInt(KEY_PENDING, 0).toString()
)
views.setTextViewText(
R.id.widget_quota,
"${prefs.getInt(KEY_QUOTA_USED, 0)}/${prefs.getInt(KEY_QUOTA_LIMIT, 0)}"
)
val lastUpdate = prefs.getString(KEY_LAST_UPDATE, "") ?: ""
views.setTextViewText(
R.id.widget_last_update,
if (lastUpdate.length >= 16) lastUpdate.substring(11, 16) else "--:--"
)
// Tapping the scan button opens the app.
val launchIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_scanner", true)
}
val pending = PendingIntent.getActivity(
context,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_scan_button, pending)
return views
}
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (id in appWidgetIds) {
appWidgetManager.updateAppWidget(id, buildViews(context))
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("com.android.application") version "8.9.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration