Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
<!-- Android 13+ runtime permission for the "payment received" background-scan notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".MainApplication"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.facebook.react.modules.i18nmanager.I18nUtil
import org.bitshala.shroud.background.BackgroundScanPackage
import org.bitshala.shroud.components.segmentedcontrol.CustomSegmentedControlPackage

class MainApplication : Application(), ReactApplication {
Expand All @@ -29,6 +30,7 @@ class MainApplication : Application(), ReactApplication {
// add(MyReactNativePackage())
add(CustomSegmentedControlPackage())
add(RustJsiBridgePackage())
add(BackgroundScanPackage())
}

override fun getJSMainModuleName(): String = "index"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package org.bitshala.shroud.background

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 androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.PermissionAwareActivity
import com.facebook.react.modules.core.PermissionListener
import org.bitshala.shroud.MainActivity
import org.bitshala.shroud.R
import java.util.concurrent.TimeUnit

class BackgroundScanModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {

companion object {
const val NAME = "BackgroundScanManager"
const val WORK_NAME = "shroud-bg-scan"
const val CHANNEL_ID = "shroud_scan"
private const val PERIOD_MINUTES = 15L
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 4352
private const val PREFS_NAME = "group.org.bitshala.shroud"
private const val PREF_ENABLED = "background_scan_enabled"
private const val PREF_LAST_RUN_AT = "background_scan_last_run_at"
}

override fun getName() = NAME

private val prefs
get() = reactApplicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

@ReactMethod
fun start(promise: Promise) {
try {
val request = PeriodicWorkRequestBuilder<BackgroundScanWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setInputData(workDataOf(BackgroundScanWorker.KEY_TIME_BUDGET_MS to BackgroundScanWorker.DEFAULT_TIME_BUDGET_MS))
.build()

WorkManager.getInstance(reactApplicationContext)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)

prefs.edit().putBoolean(PREF_ENABLED, true).apply()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("bg_scan_start_failed", e)
}
}

@ReactMethod
fun stop(promise: Promise) {
try {
WorkManager.getInstance(reactApplicationContext).cancelUniqueWork(WORK_NAME)
prefs.edit().putBoolean(PREF_ENABLED, false).apply()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("bg_scan_stop_failed", e)
}
}

@ReactMethod
fun getStatus(promise: Promise) {
try {
val lastRunAt = prefs.getLong(PREF_LAST_RUN_AT, 0L)
val result = com.facebook.react.bridge.Arguments.createMap().apply {
putBoolean("enabled", prefs.getBoolean(PREF_ENABLED, false))
if (lastRunAt > 0) putDouble("lastRunAt", lastRunAt.toDouble()) else putNull("lastRunAt")
putBoolean("available", true)
}
promise.resolve(result)
} catch (e: Exception) {
promise.reject("bg_scan_status_failed", e)
}
}

/**
* No-op on Android: the headless task's resolved promise notifies completion
* (AppRegistry → HeadlessJsTaskContext.finishTask). Kept for iOS API symmetry.
*/
@ReactMethod
fun finish(taskId: String?, success: Boolean) = Unit

@ReactMethod
fun postNotification(title: String, body: String, promise: Promise) {
try {
val context = reactApplicationContext
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
promise.resolve(null) // silently skip; permission is requested from the foreground flow
return
}

ensureChannel(context)

val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(
android.app.PendingIntent.getActivity(
context,
0,
android.content.Intent(context, MainActivity::class.java),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE,
),
)
.build()

NotificationManagerCompat.from(context).notify(System.currentTimeMillis().toInt(), notification)
promise.resolve(null)
} catch (e: Exception) {
promise.reject("bg_scan_notify_failed", e)
}
}

@ReactMethod
fun requestNotificationPermission(promise: Promise) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
promise.resolve(true)
return
}

val context = reactApplicationContext
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
promise.resolve(true)
return
}

val activity = currentActivity as? PermissionAwareActivity
if (activity == null) {
promise.resolve(false)
return
}

val listener = PermissionListener { requestCode, _, grantResults ->
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
promise.resolve(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)
true
} else {
false
}
}

activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), NOTIFICATION_PERMISSION_REQUEST_CODE, listener)
}

private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Incoming payments", NotificationManager.IMPORTANCE_DEFAULT),
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.bitshala.shroud.background

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class BackgroundScanPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(BackgroundScanModule(reactContext))

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package org.bitshala.shroud.background

import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.facebook.react.ReactApplication
import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.LifecycleState
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

/**
* Runs the "BackgroundScan" headless JS task and blocks until it completes, so
* WorkManager's wakelock and process priority cover the whole scan. Drives
* HeadlessJsTaskContext directly instead of going through a HeadlessJsTaskService:
* no startService() background restrictions, and completion is observable.
*
* Cold process: boots the ReactContext headlessly (same path HeadlessJsTaskService
* uses) before starting the task.
*/
class BackgroundScanWorker(appContext: Context, params: WorkerParameters) : Worker(appContext, params) {

companion object {
const val TAG = "BackgroundScanWorker"
const val KEY_TIME_BUDGET_MS = "timeBudgetMs"
const val DEFAULT_TIME_BUDGET_MS = 8L * 60 * 1000 // stay well inside the 10-min Worker window
private const val REACT_BOOT_TIMEOUT_MS = 30L * 1000
private const val TASK_COMPLETION_GRACE_MS = 30L * 1000
}

override fun doWork(): Result {
val timeBudgetMs = inputData.getLong(KEY_TIME_BUDGET_MS, DEFAULT_TIME_BUDGET_MS)

val reactContext = awaitReactContext() ?: run {
Log.w(TAG, "Could not obtain ReactContext, retrying later")
return Result.retry()
}

if (reactContext.lifecycleState == LifecycleState.RESUMED) {
// App is in the foreground — the live app owns scanning.
Log.i(TAG, "App in foreground, skipping background scan")
return Result.success()
}

return if (runHeadlessTask(reactContext, timeBudgetMs)) Result.success() else Result.retry()
}

private fun awaitReactContext(): ReactContext? {
val reactNativeHost = (applicationContext as ReactApplication).reactNativeHost
val reactInstanceManager = reactNativeHost.reactInstanceManager

reactInstanceManager.currentReactContext?.let { return it }

val latch = CountDownLatch(1)
var obtainedContext: ReactContext? = null

val listener = object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
obtainedContext = context
reactInstanceManager.removeReactInstanceEventListener(this)
latch.countDown()
}
}

UiThreadUtil.runOnUiThread {
// Re-check on the UI thread: the context may have appeared in between.
val current = reactInstanceManager.currentReactContext
if (current != null) {
obtainedContext = current
latch.countDown()
} else {
reactInstanceManager.addReactInstanceEventListener(listener)
if (!reactInstanceManager.hasStartedCreatingInitialContext()) {
reactInstanceManager.createReactContextInBackground()
}
}
}

latch.await(REACT_BOOT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
if (obtainedContext == null) reactInstanceManager.removeReactInstanceEventListener(listener)
return obtainedContext
}

private fun runHeadlessTask(reactContext: ReactContext, timeBudgetMs: Long): Boolean {
val taskContext = HeadlessJsTaskContext.getInstance(reactContext)
val latch = CountDownLatch(1)
var startedTaskId = -1

val listener = object : HeadlessJsTaskEventListener {
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
override fun onHeadlessJsTaskFinish(taskId: Int) {
if (taskId == startedTaskId) latch.countDown()
}
}

taskContext.addTaskEventListener(listener)
try {
UiThreadUtil.runOnUiThread {
try {
val data = Arguments.createMap().apply {
putDouble(KEY_TIME_BUDGET_MS, timeBudgetMs.toDouble())
putString("reason", "android-worker")
}
startedTaskId = taskContext.startTask(
HeadlessJsTaskConfig(
"BackgroundScan",
data,
timeBudgetMs + TASK_COMPLETION_GRACE_MS,
false, // not allowed in foreground; pre-checked above but races are possible
),
)
} catch (e: IllegalStateException) {
// App moved to foreground between our check and startTask.
Log.i(TAG, "Headless task rejected: ${e.message}")
latch.countDown()
}
}

val finished = latch.await(timeBudgetMs + TASK_COMPLETION_GRACE_MS, TimeUnit.MILLISECONDS)
if (!finished) Log.w(TAG, "Headless task did not finish within budget")
return finished
} finally {
taskContext.removeTaskEventListener(listener)
markLastRun()
}
}

private fun markLastRun() {
applicationContext
.getSharedPreferences("group.org.bitshala.shroud", Context.MODE_PRIVATE)
.edit()
.putLong("background_scan_last_run_at", System.currentTimeMillis())
.apply()
}
}
Loading
Loading