Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user