(feat): add mobile notifications
(feat): improve mobile ui & ux
This commit is contained in:
parent
4cb38264d0
commit
13fa3d8db7
29 changed files with 6638 additions and 6094 deletions
1563
apps/tauri/src-tauri/Cargo.lock
generated
1563
apps/tauri/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,12 +15,18 @@ name = "mobile_lib"
|
|||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", features = [] }
|
||||
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "88e5851ba21074e260d0ed4c4dc46f9d24365559", features = ["client", "crypto"] }
|
||||
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "88e5851ba21074e260d0ed4c4dc46f9d24365559" }
|
||||
webpki-root-certs = "1"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-log = "2"
|
||||
|
|
@ -30,10 +36,12 @@ version = "2"
|
|||
features = []
|
||||
default-features = true
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
|
||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||
tauri-plugin-barcode-scanner = "2"
|
||||
tauri-plugin-app-events = "0.2"
|
||||
|
||||
[patch.crates-io.tauri]
|
||||
git = "https://github.com/tauri-apps/tauri"
|
||||
branch = "feat/cef"
|
||||
rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
],
|
||||
"permissions": [
|
||||
"deep-link:default",
|
||||
"app-events:default",
|
||||
"barcode-scanner:default",
|
||||
"barcode-scanner:allow-scan",
|
||||
"barcode-scanner:allow-cancel",
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@
|
|||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
@ -50,6 +53,24 @@
|
|||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name=".MtpForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the user-enabled encrypted messaging connection and receives incoming messages" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".MtpBootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class MainActivity : TauriActivity() {
|
|||
}
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
webView.setInitialScale(300)
|
||||
webView.setInitialScale(290)
|
||||
mediaWebView = webView
|
||||
MobileMediaEvents.attach(webView)
|
||||
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||
|
|
@ -66,9 +66,23 @@ class MainActivity : TauriActivity() {
|
|||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
super.onCreate(savedInstanceState)
|
||||
NativeMtpBridge.nativeAttach(applicationContext)
|
||||
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||
NativeMtpBridge.startService(this)
|
||||
}
|
||||
installKeyboardResizeWorkaround()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NativeMtpBridge.nativeSetUiState(true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
NativeMtpBridge.nativeSetUiState(false)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
attachLayoutListener?.let { listener ->
|
||||
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class MtpBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (
|
||||
intent.action == Intent.ACTION_BOOT_COMPLETED &&
|
||||
MtpSecureStore.isEnabled(context) &&
|
||||
MtpSecureStore.hasConfig(context)
|
||||
) {
|
||||
NativeMtpBridge.startService(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class MtpForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
MtpSecureStore.setEnabled(this, false)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
createChannel(this)
|
||||
val notification = buildNotification(this, "Connecting")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
val config = MtpSecureStore.loadConfig(this)
|
||||
if (config == null || !MtpSecureStore.isEnabled(this)) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
try {
|
||||
NativeMtpBridge.nativeAttach(applicationContext)
|
||||
NativeMtpBridge.nativeStart(config)
|
||||
NativeMtpBridge.log(2, "Started MTP foreground service")
|
||||
} catch (error: Throwable) {
|
||||
NativeMtpBridge.log(0, "Failed to start MTP foreground service", error)
|
||||
updateNotification(this, "Connection failed")
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "tensamin-connection"
|
||||
private const val NOTIFICATION_ID = 2201
|
||||
private const val ACTION_STOP = "net.tensamin.client.STOP_MTP"
|
||||
|
||||
fun updateNotification(context: Context, status: String) {
|
||||
if (!MtpSecureStore.isEnabled(context)) return
|
||||
createChannel(context)
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, buildNotification(context, status))
|
||||
}
|
||||
|
||||
private fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Background connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = "Keeps Tensamin connected for incoming messages" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(context: Context, status: String): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val stopIntent = PendingIntent.getService(
|
||||
context,
|
||||
1,
|
||||
Intent(context, MtpForegroundService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("Tensamin background connection")
|
||||
.setContentText(status)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
object MtpSecureStore {
|
||||
private const val KEY_ALIAS = "tensamin-mtp-config"
|
||||
private const val PREFS = "tensamin-mtp"
|
||||
private const val CONFIG = "config"
|
||||
private const val ENABLED = "enabled"
|
||||
|
||||
fun saveConfig(context: Context, config: String) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
val encrypted = cipher.doFinal(config.toByteArray(Charsets.UTF_8))
|
||||
val payload = Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP)
|
||||
preferences(context).edit().putString(CONFIG, payload).apply()
|
||||
}
|
||||
|
||||
fun loadConfig(context: Context): String? {
|
||||
val payload = preferences(context).getString(CONFIG, null) ?: return null
|
||||
return runCatching {
|
||||
val bytes = Base64.decode(payload, Base64.NO_WRAP)
|
||||
require(bytes.size > 12)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
getOrCreateKey(),
|
||||
GCMParameterSpec(128, bytes.copyOfRange(0, 12)),
|
||||
)
|
||||
String(cipher.doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun hasConfig(context: Context): Boolean = loadConfig(context) != null
|
||||
|
||||
fun setEnabled(context: Context, enabled: Boolean) {
|
||||
preferences(context).edit().putBoolean(ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun isEnabled(context: Context): Boolean =
|
||||
preferences(context).getBoolean(ENABLED, false)
|
||||
|
||||
private fun preferences(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
|
||||
val generator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
"AndroidKeyStore",
|
||||
)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@Keep
|
||||
object NativeMtpBridge {
|
||||
private const val MESSAGE_CHANNEL = "tensamin-messages"
|
||||
|
||||
init {
|
||||
System.loadLibrary("mobile_lib")
|
||||
}
|
||||
|
||||
@JvmStatic external fun nativeAttach(context: Context)
|
||||
@JvmStatic external fun nativeStart(config: String)
|
||||
@JvmStatic external fun nativeStop()
|
||||
@JvmStatic external fun nativeSetUiState(visible: Boolean)
|
||||
@JvmStatic external fun nativeLog(level: Int, message: String, details: String)
|
||||
|
||||
fun log(level: Int, message: String, error: Throwable? = null) {
|
||||
val details = error?.stackTraceToString().orEmpty()
|
||||
Log.println(if (level == 0) Log.ERROR else Log.INFO, "TensaminAndroid", "$message $details")
|
||||
nativeLog(level, message, details)
|
||||
}
|
||||
|
||||
fun storeConfig(context: Context, config: String) {
|
||||
try {
|
||||
MtpSecureStore.saveConfig(context, config)
|
||||
if (MtpSecureStore.isEnabled(context)) startService(context)
|
||||
log(2, "Stored native MTP credentials")
|
||||
} catch (error: Throwable) {
|
||||
log(0, "Failed to store native MTP credentials", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun hasConfig(context: Context): Boolean = MtpSecureStore.hasConfig(context)
|
||||
|
||||
fun setServiceEnabled(context: Context, enabled: Boolean) {
|
||||
MtpSecureStore.setEnabled(context, enabled)
|
||||
if (enabled && MtpSecureStore.hasConfig(context)) startService(context) else stopService(context)
|
||||
}
|
||||
|
||||
fun isIgnoringBatteryOptimizations(context: Context): Boolean =
|
||||
context.getSystemService(PowerManager::class.java)
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
|
||||
fun requestBatteryExemption(context: Context) {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, MtpForegroundService::class.java),
|
||||
)
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
if (!context.stopService(Intent(context, MtpForegroundService::class.java))) nativeStop()
|
||||
}
|
||||
|
||||
fun updateServiceStatus(context: Context, status: String) {
|
||||
MtpForegroundService.updateNotification(context, status)
|
||||
}
|
||||
|
||||
fun postMessageNotification(
|
||||
context: Context,
|
||||
senderId: Long,
|
||||
sender: String,
|
||||
body: String,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
MESSAGE_CHANNEL,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { description = "Incoming Tensamin messages" },
|
||||
)
|
||||
}
|
||||
val openIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("tensamin://chat?id=$senderId"),
|
||||
context,
|
||||
MainActivity::class.java,
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
senderId.hashCode(),
|
||||
openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.sym_action_chat)
|
||||
.setContentTitle(sender)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.notify(senderId.hashCode(), notification)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
mod mtp_backend;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.level(tauri_plugin_log::log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
let builder = builder
|
||||
|
|
@ -11,11 +17,20 @@ pub fn run() {
|
|||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_app_events::init());
|
||||
|
||||
if let Err(error) = builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
mtp_backend::mtp_request,
|
||||
mtp_backend::mtp_status,
|
||||
mtp_backend::mtp_store_credentials,
|
||||
mtp_backend::mtp_has_credentials,
|
||||
mtp_backend::mtp_load_keyring,
|
||||
mtp_backend::mtp_set_enabled,
|
||||
mtp_backend::mtp_set_ui_visible,
|
||||
mtp_backend::mtp_is_ignoring_battery_optimizations,
|
||||
mtp_backend::mtp_request_battery_exemption,
|
||||
])
|
||||
.setup(|_app| {
|
||||
mtp_backend::manager().attach_app(_app.handle().clone());
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
|
|
|||
1092
apps/tauri/src-tauri/src/mtp_backend.rs
Normal file
1092
apps/tauri/src-tauri/src/mtp_backend.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue