Skip to content
Closed
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
6 changes: 4 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ jobs:
distribution: 'temurin'
cache: gradle

- name: Setup Android SDK
uses: android-actions/setup-android@v3
# Runner ubuntu-latest sudah menyertakan Android SDK (ANDROID_SDK_ROOT).
# setup-android@v3 dihapus karena crash saat accept licenses di Node 24.
- name: Accept Android licenses
run: yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true

- name: Grant execute permission for gradlew
run: chmod +x gradlew
Expand Down
11 changes: 7 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ plugins {
}

android {
namespace = "com.itsazni.notificationforwarder"
namespace = "my.id.adngopi.bridge"
compileSdk = 36

defaultConfig {
applicationId = "com.itsazni.notificationforwarder"
applicationId = "my.id.adngopi.bridge"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.1.0"
versionCode = 2
versionName = "1.2.0-formify.1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -76,4 +76,7 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")

// Formify: QR config scanner (dashboard provisioning)
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
}
21 changes: 18 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Formify: hanya untuk scan QR config dari dashboard -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />

<application
android:name=".NotificationForwarderApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Notification Forwarder"
android:label="Formify Bridge"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
Expand All @@ -24,10 +28,21 @@
</intent-filter>
</activity>

<!-- Formify: layar scan QR config -->
<activity
android:name=".ScanConfigActivity"
android:exported="false" />

<!-- zxing CaptureActivity: paksa exported=false agar merger lolos di targetSdk 31+ -->
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:exported="false"
tools:replace="android:exported" />

<service
android:name=".service.AppNotificationListenerService"
android:exported="true"
android:label="Notification Forwarder"
android:label="Formify Bridge"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
Expand Down
56 changes: 56 additions & 0 deletions app/src/main/java/my/id/adngopi/bridge/BridgeConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package my.id.adngopi.bridge

import android.content.Context
import com.google.gson.Gson
import com.google.gson.JsonObject
import my.id.adngopi.bridge.settings.AuthMode
import my.id.adngopi.bridge.settings.FilterMode
import my.id.adngopi.bridge.settings.SettingsStore

/**
* Formify Bridge provisioning.
*
* QR config JSON dari dashboard Formify, contoh:
* {
* "v": 1,
* "webhookUrl": "https://adngopi.my.id/api/webhooks/dana-bridge",
* "bearerToken": "<per-device secret>",
* "filterPackages": ["id.dana"],
* "deviceLabel": "HP-KASIR-01"
* }
*/
object BridgeConfig {
const val CONFIG_VERSION = 1

fun applyJson(context: Context, json: String): Boolean {
return try {
val obj = Gson().fromJson(json, JsonObject::class.java) ?: return false
if (!obj.has("webhookUrl") || obj.get("webhookUrl").asString.isBlank()) return false
val version = if (obj.has("v")) obj.get("v").asInt else 1
if (version != CONFIG_VERSION) return false

val store = SettingsStore(context)
store.webhookUrl = obj.get("webhookUrl").asString.trim()
if (obj.has("webhookMethod")) {
store.webhookMethod = obj.get("webhookMethod").asString
}
store.authMode = AuthMode.BEARER
if (obj.has("bearerToken")) {
store.bearerToken = obj.get("bearerToken").asString
}
store.filterMode = FilterMode.WHITELIST
if (obj.has("filterPackages")) {
val pkgs = obj.getAsJsonArray("filterPackages").map { it.asString.trim() }
.filter { it.isNotEmpty() }.toSet()
if (pkgs.isNotEmpty()) store.filterPackages = pkgs
}
if (obj.has("deviceLabel")) {
store.customHeadersRaw = "X-Device-Label: ${obj.get("deviceLabel").asString.trim()}"
}
store.forwardingEnabled = true
true
} catch (_: Exception) {
false
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsazni.notificationforwarder
package my.id.adngopi.bridge

import android.content.ComponentName
import android.content.Context
Expand Down Expand Up @@ -59,17 +59,17 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.itsazni.notificationforwarder.data.NotificationRepository
import com.itsazni.notificationforwarder.data.QueueItem
import com.itsazni.notificationforwarder.data.QueueStats
import com.itsazni.notificationforwarder.data.QueueStatus
import com.itsazni.notificationforwarder.network.WebhookClient
import com.itsazni.notificationforwarder.settings.AppSettings
import com.itsazni.notificationforwarder.settings.AuthMode
import com.itsazni.notificationforwarder.settings.FilterMode
import com.itsazni.notificationforwarder.settings.SettingsStore
import com.itsazni.notificationforwarder.ui.theme.AppTheme
import com.itsazni.notificationforwarder.worker.WorkerScheduler
import my.id.adngopi.bridge.data.NotificationRepository
import my.id.adngopi.bridge.data.QueueItem
import my.id.adngopi.bridge.data.QueueStats
import my.id.adngopi.bridge.data.QueueStatus
import my.id.adngopi.bridge.network.WebhookClient
import my.id.adngopi.bridge.settings.AppSettings
import my.id.adngopi.bridge.settings.AuthMode
import my.id.adngopi.bridge.settings.FilterMode
import my.id.adngopi.bridge.settings.SettingsStore
import my.id.adngopi.bridge.ui.theme.AppTheme
import my.id.adngopi.bridge.worker.WorkerScheduler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -303,6 +303,12 @@ private fun HomeScreen(modifier: Modifier, stats: QueueStats) {
) {
Text("Sync Queue")
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { context.startActivity(Intent(context, ScanConfigActivity::class.java)) }
) {
Text("Scan QR Config Formify")
}
}
}
}
Expand Down Expand Up @@ -692,7 +698,7 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
if (enabled.isNullOrBlank()) {
return false
}
val target = ComponentName(context, com.itsazni.notificationforwarder.service.AppNotificationListenerService::class.java)
val target = ComponentName(context, my.id.adngopi.bridge.service.AppNotificationListenerService::class.java)
return enabled.contains(target.flattenToString())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.itsazni.notificationforwarder
package my.id.adngopi.bridge

import android.app.Application
import com.itsazni.notificationforwarder.worker.WorkerScheduler
import my.id.adngopi.bridge.worker.WorkerScheduler

class NotificationForwarderApp : Application() {
override fun onCreate() {
Expand Down
42 changes: 42 additions & 0 deletions app/src/main/java/my/id/adngopi/bridge/ScanConfigActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package my.id.adngopi.bridge

import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import com.google.zxing.integration.android.IntentIntegrator

/**
* Scan QR config dari dashboard Formify, terapkan via [BridgeConfig], lalu tutup.
* Dibuka dari tombol "Scan QR Config Formify" di tab Home.
*/
class ScanConfigActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
IntentIntegrator(this)
.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
.setPrompt("Scan QR config dari dashboard Formify")
.setBeepEnabled(true)
.setOrientationLocked(false)
.initiateScan()
}

@Deprecated("Used by zxing IntentIntegrator")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null && !result.contents.isNullOrBlank()) {
val applied = BridgeConfig.applyJson(this, result.contents)
Toast.makeText(
this,
if (applied) "Config Formify diterapkan. Cek tab Webhook & Filter."
else "QR tidak valid (bukan config Formify).",
Toast.LENGTH_LONG
).show()
} else {
Toast.makeText(this, "Scan dibatalkan.", Toast.LENGTH_SHORT).show()
}
finish()
super.onActivityResult(requestCode, resultCode, data)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsazni.notificationforwarder.data
package my.id.adngopi.bridge.data

import android.content.Context
import androidx.room.Database
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.itsazni.notificationforwarder.data
package my.id.adngopi.bridge.data

import android.content.Context
import com.itsazni.notificationforwarder.settings.FilterMode
import com.itsazni.notificationforwarder.settings.SettingsStore
import my.id.adngopi.bridge.settings.FilterMode
import my.id.adngopi.bridge.settings.SettingsStore

class NotificationRepository(private val context: Context) {
private val dao = AppDatabase.getInstance(context).queueDao()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsazni.notificationforwarder.data
package my.id.adngopi.bridge.data

import androidx.room.Dao
import androidx.room.Insert
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsazni.notificationforwarder.data
package my.id.adngopi.bridge.data

import androidx.room.Entity
import androidx.room.Index
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.itsazni.notificationforwarder.network
package my.id.adngopi.bridge.network

import com.google.gson.Gson
import com.google.gson.JsonParser
import com.itsazni.notificationforwarder.data.QueueItem
import my.id.adngopi.bridge.data.QueueItem
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.itsazni.notificationforwarder.receiver
package my.id.adngopi.bridge.receiver

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.itsazni.notificationforwarder.worker.WorkerScheduler
import my.id.adngopi.bridge.worker.WorkerScheduler

class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package com.itsazni.notificationforwarder.service
package my.id.adngopi.bridge.service

import android.app.Notification
import android.os.Build
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import com.itsazni.notificationforwarder.data.NotificationRepository
import com.itsazni.notificationforwarder.worker.WorkerScheduler
import my.id.adngopi.bridge.data.NotificationRepository
import my.id.adngopi.bridge.worker.WorkerScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsazni.notificationforwarder.settings
package my.id.adngopi.bridge.settings

import android.content.Context
import android.content.SharedPreferences
Expand Down Expand Up @@ -26,24 +26,28 @@ class SettingsStore(context: Context) {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences("notif_settings", Context.MODE_PRIVATE)

// Formify defaults: webhook Formify + whitelist DANA + auth bearer (token via QR config).
var webhookUrl: String
get() = prefs.getString(KEY_WEBHOOK_URL, "") ?: ""
get() = prefs.getString(KEY_WEBHOOK_URL, FORMIFY_DEFAULT_WEBHOOK_URL) ?: FORMIFY_DEFAULT_WEBHOOK_URL
set(value) = prefs.edit { putString(KEY_WEBHOOK_URL, value.trim()) }

var forwardingEnabled: Boolean
get() = prefs.getBoolean(KEY_FORWARDING_ENABLED, true)
set(value) = prefs.edit { putBoolean(KEY_FORWARDING_ENABLED, value) }

var filterMode: FilterMode
get() = FilterMode.valueOf(prefs.getString(KEY_FILTER_MODE, FilterMode.ALL_APPS.name)!!)
get() = FilterMode.valueOf(prefs.getString(KEY_FILTER_MODE, FilterMode.WHITELIST.name)!!)
set(value) = prefs.edit { putString(KEY_FILTER_MODE, value.name) }

var filterPackages: Set<String>
get() = parsePackages(prefs.getString(KEY_FILTER_PACKAGES, "") ?: "")
get() {
val raw = prefs.getString(KEY_FILTER_PACKAGES, null)
return if (raw.isNullOrEmpty()) FORMIFY_DEFAULT_PACKAGES else parsePackages(raw)
}
set(value) = prefs.edit { putString(KEY_FILTER_PACKAGES, value.joinToString(",")) }

var authMode: AuthMode
get() = AuthMode.valueOf(prefs.getString(KEY_AUTH_MODE, AuthMode.NONE.name)!!)
get() = AuthMode.valueOf(prefs.getString(KEY_AUTH_MODE, AuthMode.BEARER.name)!!)
set(value) = prefs.edit { putString(KEY_AUTH_MODE, value.name) }

var bearerToken: String
Expand Down Expand Up @@ -124,6 +128,12 @@ class SettingsStore(context: Context) {
}

companion object {
// Ganti dengan domain produksi bila berbeda.
const val FORMIFY_DEFAULT_WEBHOOK_URL = "https://adngopi.my.id/api/webhooks/dana-bridge"
// Package DANA Indonesia. Verifikasi di tab Queue setelah install:
// jika notifikasi DANA tidak masuk, sesuaikan whitelist dengan package yang tampil.
val FORMIFY_DEFAULT_PACKAGES: Set<String> = setOf("id.dana")

private const val KEY_WEBHOOK_URL = "webhook_url"
private const val KEY_FORWARDING_ENABLED = "forwarding_enabled"
private const val KEY_FILTER_MODE = "filter_mode"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.itsazni.notificationforwarder.ui.theme
package my.id.adngopi.bridge.ui.theme

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.itsazni.notificationforwarder.worker
package my.id.adngopi.bridge.worker

import android.content.Context
import android.provider.Settings
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.itsazni.notificationforwarder.data.NotificationRepository
import com.itsazni.notificationforwarder.network.WebhookClient
import com.itsazni.notificationforwarder.settings.AuthMode
import com.itsazni.notificationforwarder.settings.SettingsStore
import my.id.adngopi.bridge.data.NotificationRepository
import my.id.adngopi.bridge.network.WebhookClient
import my.id.adngopi.bridge.settings.AuthMode
import my.id.adngopi.bridge.settings.SettingsStore

class QueueWorker(
appContext: Context,
Expand Down
Loading
Loading