diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3cbbfd7..0d66924 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,13 +1,15 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
-}
-
-kotlin {
- jvmToolchain(17)
+ alias(libs.plugins.compose.compiler)
}
android {
+ compileOptions {
+ targetCompatibility = JavaVersion.VERSION_17
+ sourceCompatibility = JavaVersion.VERSION_17
+ }
+
compileSdk = 35
defaultConfig {
@@ -18,6 +20,10 @@ android {
versionName = "1.9.0"
}
+ buildFeatures {
+ compose = true
+ }
+
buildTypes {
getByName("release") {
resValue("string", "app_name", "NextPush")
@@ -57,10 +63,11 @@ if (project.hasProperty("sign")) {
}
}
-
dependencies {
+ implementation(libs.androidx.activity.compose)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.coordinatorlayout)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.appcompat)
implementation(libs.kotlin.stdlib)
@@ -72,4 +79,7 @@ dependencies {
implementation(libs.retrofit.retrofit)
implementation(libs.rxjava3.rxandroid)
implementation(libs.rxjava3.rxjava)
+ implementation(libs.androidx.material3.android)
+ debugImplementation(libs.androidx.ui.tooling.preview.android)
+ debugImplementation(libs.androidx.ui.tooling)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 58683b8..3d5a3ae 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -34,11 +34,8 @@
-
+
-
diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/EventBus.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/EventBus.kt
new file mode 100644
index 0000000..7f7fcb1
--- /dev/null
+++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/EventBus.kt
@@ -0,0 +1,28 @@
+package org.unifiedpush.distributor.nextpush
+
+import android.util.Log
+import kotlin.coroutines.coroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.filterIsInstance
+
+object EventBus {
+ val mutEvents: MutableSharedFlow = MutableSharedFlow()
+ val events = mutEvents.asSharedFlow()
+
+ suspend inline fun publish(event: T) {
+ if (mutEvents.subscriptionCount.value > 0) {
+ mutEvents.emit(event)
+ }
+ }
+
+ suspend inline fun subscribe(crossinline onEvent: (T) -> Unit) {
+ events.filterIsInstance()
+ .collectLatest { event ->
+ coroutineContext.ensureActive()
+ onEvent(event)
+ }
+ }
+}
diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/AppAction.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/AppAction.kt
new file mode 100644
index 0000000..ef3fe20
--- /dev/null
+++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/AppAction.kt
@@ -0,0 +1,125 @@
+package org.unifiedpush.distributor.nextpush.activities
+
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.launch
+import org.unifiedpush.distributor.nextpush.AppStore
+import org.unifiedpush.distributor.nextpush.EventBus
+import org.unifiedpush.distributor.nextpush.LocalNotification
+import org.unifiedpush.distributor.nextpush.account.AccountFactory
+import org.unifiedpush.distributor.nextpush.distributor.Distributor
+import org.unifiedpush.distributor.nextpush.distributor.Distributor.deleteApp
+import org.unifiedpush.distributor.nextpush.distributor.Distributor.deleteDevice
+import org.unifiedpush.distributor.nextpush.services.FailureHandler
+import org.unifiedpush.distributor.nextpush.services.RestartWorker
+import org.unifiedpush.distributor.nextpush.services.StartService
+import org.unifiedpush.distributor.nextpush.utils.TAG
+
+class AppAction(private val type: Type, private val argv: Map? = null) {
+ enum class Type {
+ RestartService,
+ Logout,
+ AddChannel,
+ DisableBatteryOptimisation,
+ CopyEndpoint,
+ DeleteRegistration,
+ }
+
+ fun handle(context: Context) {
+ when (type) {
+ Type.RestartService -> restartService(context)
+ Type.Logout -> logout(context)
+ Type.AddChannel -> addChannel(context, argv)
+ Type.DisableBatteryOptimisation -> disableBatteryOptimisation(context)
+ Type.CopyEndpoint -> copyEndpoint(context, argv)
+ Type.DeleteRegistration -> deleteRegistration(context, argv)
+ }
+ }
+
+ private fun restartService(context: Context) {
+ Log.d(TAG, "Restarting the Listener")
+ FailureHandler.clearFails()
+ StartService.stopService {
+ RestartWorker.run(context, delay = 0)
+ }
+ }
+
+ private fun logout(context: Context) {
+ deleteDevice(context) {
+ StartService.stopService()
+ FailureHandler.clearFails()
+ }
+ AccountFactory.logout(context)
+ AppStore(context).wipe()
+ UiAction.publish(UiAction.Type.Logout)
+ }
+
+ private fun addChannel(context: Context, argv: Map?) {
+ (argv?.get(ARG_NEW_CHANNEL_TITLE) as String?)?.let {
+ LocalNotification.createChannel(
+ context,
+ it
+ ) {
+ Log.d(TAG, "Channel \"$it\" created")
+ UiAction.publish(UiAction.Type.UpdateRegistrations)
+ }
+ }
+ }
+
+ @SuppressLint("BatteryLife")
+ private fun disableBatteryOptimisation(context: Context) {
+ Log.d(TAG, "Disabling battery optimization")
+ try {
+ context.startActivity(
+ Intent(
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ Uri.parse("package:${context.packageName}")
+ )
+ )
+ } catch (e: ActivityNotFoundException) {
+ try {
+ context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
+ } catch (e2: ActivityNotFoundException) {
+ context.startActivity(Intent(Settings.ACTION_SETTINGS))
+ }
+ }
+ }
+
+ private fun copyEndpoint(context: Context, argv: Map?) {
+ val token = argv?.get(ARG_TOKEN) as String? ?: return
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip: ClipData = ClipData.newPlainText(
+ "Endpoint",
+ Distributor.getEndpoint(context, token)
+ )
+ clipboard.setPrimaryClip(clip)
+ }
+
+ private fun deleteRegistration(context: Context, argv: Map?) {
+ val registrations = argv?.get(ARG_REGISTRATIONS) as List? ?: return
+ registrations?.forEach {
+ deleteApp(context, it) {}
+ }
+ }
+
+ companion object {
+ const val ARG_NEW_CHANNEL_TITLE = "title"
+ const val ARG_TOKEN = "token"
+ const val ARG_REGISTRATIONS = "registrations"
+ }
+}
+
+fun ViewModel.publishAction(action: AppAction) {
+ viewModelScope.launch {
+ EventBus.publish(action)
+ }
+}
diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/AppListAdapter.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/AppListAdapter.kt
deleted file mode 100644
index 66d45b4..0000000
--- a/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/AppListAdapter.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package org.unifiedpush.distributor.nextpush.activities
-
-import android.content.Context
-import android.util.SparseBooleanArray
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ArrayAdapter
-import android.widget.TextView
-import androidx.core.view.isGone
-import com.google.android.material.color.MaterialColors
-import org.unifiedpush.distributor.nextpush.Database.Companion.getDb
-import org.unifiedpush.distributor.nextpush.R
-import org.unifiedpush.distributor.nextpush.utils.getApplicationName
-
-data class App(
- val token: String,
- val packageId: String
-)
-
-class AppListAdapter(context: Context, private val resource: Int, apps: List) : ArrayAdapter(context, resource, apps) {
- private var selectedItemsIds = SparseBooleanArray()
- private val inflater = LayoutInflater.from(context)
- private val db = getDb(context)
-
- private class ViewHolder {
- var name: TextView? = null
- var description: TextView? = null
- }
-
- override fun getView(position: Int, pConvertView: View?, parent: ViewGroup): View {
- var viewHolder: ViewHolder? = null
- val convertView = pConvertView?.apply {
- viewHolder = tag as ViewHolder
- } ?: run {
- val rConvertView = inflater.inflate(resource, parent, false)
- viewHolder = ViewHolder().apply {
- this.name = rConvertView.findViewById(R.id.item_app_name) as TextView
- this.description = rConvertView.findViewById(R.id.item_description) as TextView
- }
- rConvertView.apply {
- tag = viewHolder
- }
- }
- getItem(position)?.let {
- if (it.packageId == context.packageName) {
- setViewHolderForLocalChannel(viewHolder, it)
- } else {
- setViewHolderForUnifiedPushApp(viewHolder, it)
- }
- }
- if (selectedItemsIds.get(position)) {
- convertView?.setBackgroundColor(
- MaterialColors.getColor(convertView, com.google.android.material.R.attr.colorOnTertiary)
- )
- } else {
- convertView?.setBackgroundResource(0)
- }
- return convertView
- }
-
- private fun setViewHolderForUnifiedPushApp(viewHolder: ViewHolder?, app: App) {
- context.getApplicationName(app.packageId)?.let {
- viewHolder?.name?.text = it
- viewHolder?.description?.text = app.packageId
- } ?: run {
- viewHolder?.name?.text = app.packageId
- viewHolder?.description?.isGone = true
- }
- }
-
- private fun setViewHolderForLocalChannel(viewHolder: ViewHolder?, app: App) {
- val title = db.getNotificationTitle(app.token)
- viewHolder?.name?.text = context.getString(R.string.local_notif_title).format(title)
- viewHolder?.description?.text = context.getString(R.string.local_notif_description)
- }
-
- fun toggleSelection(position: Int) {
- selectView(position, !selectedItemsIds.get(position))
- }
-
- fun removeSelection() {
- selectedItemsIds = SparseBooleanArray()
- notifyDataSetChanged()
- }
-
- private fun selectView(position: Int, value: Boolean) {
- selectedItemsIds.put(position, value)
- notifyDataSetChanged()
- }
-
- fun getSelectedIds(): SparseBooleanArray {
- return selectedItemsIds
- }
-}
diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/MainActivity.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/MainActivity.kt
index c3931d0..8e6e343 100644
--- a/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/MainActivity.kt
+++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/MainActivity.kt
@@ -1,366 +1,82 @@
package org.unifiedpush.distributor.nextpush.activities
-import android.annotation.SuppressLint
-import android.content.ActivityNotFoundException
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
-import android.net.Uri
import android.os.Bundle
-import android.os.PowerManager
-import android.provider.Settings
-import android.text.Html
-import android.text.InputType
-import android.text.SpannableStringBuilder
import android.util.Log
-import android.util.TypedValue
-import android.view.ActionMode
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.* // ktlint-disable no-wildcard-imports
-import android.widget.AbsListView.MultiChoiceModeListener
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.util.size
-import androidx.core.view.isGone
-import androidx.core.view.setPadding
-import com.google.android.material.card.MaterialCardView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import org.unifiedpush.distributor.nextpush.AppStore
-import org.unifiedpush.distributor.nextpush.Database.Companion.getDb
-import org.unifiedpush.distributor.nextpush.LocalNotification
-import org.unifiedpush.distributor.nextpush.R
-import org.unifiedpush.distributor.nextpush.account.AccountFactory
-import org.unifiedpush.distributor.nextpush.activities.PermissionsRequest.requestAppPermissions
-import org.unifiedpush.distributor.nextpush.activities.StartActivity.Companion.goToStartActivity
-import org.unifiedpush.distributor.nextpush.distributor.Distributor
-import org.unifiedpush.distributor.nextpush.distributor.Distributor.deleteApp
-import org.unifiedpush.distributor.nextpush.distributor.Distributor.deleteDevice
-import org.unifiedpush.distributor.nextpush.services.FailureHandler
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.lifecycle.viewmodel.compose.viewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.unifiedpush.distributor.nextpush.EventBus
+import org.unifiedpush.distributor.nextpush.activities.ui.MainUi
+import org.unifiedpush.distributor.nextpush.activities.ui.theme.AppTheme
import org.unifiedpush.distributor.nextpush.services.RestartWorker
-import org.unifiedpush.distributor.nextpush.services.StartService
import org.unifiedpush.distributor.nextpush.utils.TAG
-import org.unifiedpush.distributor.nextpush.utils.copyToClipboard
-import org.unifiedpush.distributor.nextpush.utils.getDebugInfo
-import java.lang.String.format
-class MainActivity : AppCompatActivity() {
-
- private lateinit var listView: ListView
-
- // if the unregister dialog is shown, we prevent the list to be reset
- private var preventListReset = false
- private var lastClickTime = 0L
- private var clickCount = 0
+class MainActivity : ComponentActivity() {
+ private var viewModel: MainViewModel? = null
+ private var jobs: MutableList = emptyList().toMutableList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
- setSupportActionBar(findViewById(R.id.toolbar))
- this.requestAppPermissions()
- if (AccountFactory.getAccount(this)?.connected != true) {
- Log.d(TAG, "Not connected: going to StartActivity.")
- goToStartActivity(this)
- finish()
- }
-
- findViewById(R.id.main_account_desc).text =
- format(getString(R.string.main_account_desc), AccountFactory.getAccount(this)?.name)
- invalidateOptionsMenu()
RestartWorker.startPeriodic(this)
- setDebugInformationListener()
- findViewById(android.R.id.content)?.setOnApplyWindowInsetsListener { _, insets ->
- val statusBarSize = insets.systemWindowInsetTop
- findViewById(R.id.toolbar).setPadding(0, statusBarSize , 0, 0)
- return@setOnApplyWindowInsetsListener insets
+
+ setContent {
+ val viewModel =
+ viewModel {
+ MainViewModel(this@MainActivity)
+ }.also {
+ viewModel = it
+ }
+ AppTheme {
+ MainUi(viewModel)
+ }
+ subscribeActions()
}
}
- override fun onStart() {
- super.onStart()
- showOptimisationWarning()
- }
-
- override fun onWindowFocusChanged(hasFocus: Boolean) {
- super.onWindowFocusChanged(hasFocus)
- if (hasFocus) {
- if (preventListReset) {
- preventListReset = false
- } else {
- setListView()
- }
+ private fun subscribeActions() {
+ Log.d(TAG, "Subscribing to actions")
+ jobs += CoroutineScope(Dispatchers.IO).launch {
+ EventBus.subscribe { it.handle(this@MainActivity) }
}
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_main, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_restart -> {
- restart()
- return true
- }
- R.id.action_logout -> {
- logout()
- return true
- }
- R.id.action_add_local_channel -> {
- addChannel()
- return true
- }
-
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- @SuppressLint("BatteryLife")
- private fun showOptimisationWarning() {
- val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
- if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
- findViewById(R.id.card_battery_optimization)?.isGone = false
- findViewById