(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
2
apps/tauri/.cargo/config.toml
Normal file
2
apps/tauri/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }
|
||||
53
apps/tauri/scripts/delete-mobile.ts
Normal file
53
apps/tauri/scripts/delete-mobile.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const devicesResult = spawnSync("adb", ["devices", "-l"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (devicesResult.status !== 0) {
|
||||
process.stderr.write(devicesResult.stderr);
|
||||
process.exit(devicesResult.status ?? 1);
|
||||
}
|
||||
|
||||
const devices = devicesResult.stdout
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /\sdevice(?:\s|$)/.test(line));
|
||||
|
||||
if (devices.length === 0) {
|
||||
console.error("No connected ADB devices found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let selectedDevice = devices[0];
|
||||
|
||||
if (devices.length > 1) {
|
||||
console.log("Select a device:");
|
||||
devices.forEach((device, index) => console.log(`${index + 1}) ${device}`));
|
||||
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const answer = await readline.question("Device: ");
|
||||
readline.close();
|
||||
|
||||
const selectedIndex = Number(answer) - 1;
|
||||
if (!Number.isInteger(selectedIndex) || !devices[selectedIndex]) {
|
||||
console.error("Invalid device selection.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
selectedDevice = devices[selectedIndex];
|
||||
}
|
||||
|
||||
const serial = selectedDevice.split(/\s+/, 1)[0];
|
||||
const uninstallResult = spawnSync(
|
||||
"adb",
|
||||
["-s", serial, "uninstall", "net.tensamin.client.dev"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
process.exit(uninstallResult.status ?? 1);
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -13,7 +13,7 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { z } from "zod";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
|
|
@ -64,7 +64,8 @@ function parseTuFileContent(rawFileContent: string): {
|
|||
throw new Error("Invalid file");
|
||||
}
|
||||
|
||||
const [userIdString, privateKey] = rawFileContent.split("::");
|
||||
const [userIdString, privateKeyValue] = rawFileContent.split("::");
|
||||
const privateKey = privateKeyValue.trim();
|
||||
const userId = isNaN(Number(userIdString))
|
||||
? Number(userIdString.split("@")[0])
|
||||
: Number(userIdString);
|
||||
|
|
@ -87,7 +88,7 @@ export default function Form() {
|
|||
const isMobile = useIsMobile();
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { save } = useStorage();
|
||||
const { load, save } = useStorage();
|
||||
const navigate = useNavigate();
|
||||
const loginPendingRef = useRef(false);
|
||||
|
||||
|
|
@ -97,6 +98,23 @@ export default function Form() {
|
|||
loginPendingRef.current = true;
|
||||
try {
|
||||
if (domain) await save("omega_url", `https://${domain}/`);
|
||||
if (isTauri()) {
|
||||
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
|
||||
await Promise.all([
|
||||
domain ? `https://${domain}/` : load("omega_url"),
|
||||
load("forced_omikron_url"),
|
||||
load("forced_omikron_public_key"),
|
||||
]);
|
||||
await invoke("mtp_store_credentials", {
|
||||
config: {
|
||||
userId,
|
||||
keyring: privateKey,
|
||||
omegaUrl,
|
||||
forcedOmikronUrl,
|
||||
forcedOmikronPublicKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
await save("mtp_keyring", privateKey, { secure: true });
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
|
|
@ -106,7 +124,7 @@ export default function Form() {
|
|||
loginPendingRef.current = false;
|
||||
}
|
||||
},
|
||||
[navigate, save],
|
||||
[load, navigate, save],
|
||||
);
|
||||
|
||||
// Process dropped files
|
||||
|
|
|
|||
|
|
@ -376,6 +376,8 @@
|
|||
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
|
||||
fi
|
||||
fi
|
||||
|
||||
adb devices
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@
|
|||
"dev": "cd apps/web && pnpm dev",
|
||||
"build:web": "cd apps/web && pnpm run build",
|
||||
"preview:web": "cd apps/web && pnpm run preview",
|
||||
"dev:mobile": "cd apps/tauri && pnpm dev:mobile",
|
||||
"dev:mobile": "pnpm run delete:mobile || true && cd apps/tauri && pnpm dev:mobile && pnpm run delete:mobile",
|
||||
"build:mobile": "cd apps/tauri && pnpm run build:mobile",
|
||||
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
||||
"dev:desktop": "cd apps/electron && pnpm run dev",
|
||||
"build:desktop": "cd apps/electron && pnpm run package",
|
||||
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client.dev"
|
||||
"delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useStorage } from "@tensamin/storage/context";
|
|||
import React, { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@methanium/ui";
|
||||
|
||||
import { Plus, Laugh, FileVideo } from "lucide-react";
|
||||
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
|
||||
import { useChat, useReplyMessage } from "../context";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
|
|
@ -267,6 +267,7 @@ export default function InputComponent({
|
|||
)}
|
||||
>
|
||||
<CardHeader className="relative p-0 flex flex-col">
|
||||
<div className="w-full flex items-center">
|
||||
<Input
|
||||
className="w-full"
|
||||
onControllerChange={setComposer}
|
||||
|
|
@ -280,6 +281,13 @@ export default function InputComponent({
|
|||
emojiFrequencies={emojiFrequencies}
|
||||
onEmojiSelect={recordUse}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSubmit(value)}
|
||||
className={cn("w-9! h-9!", isMobile ? "" : "hidden")}
|
||||
>
|
||||
<SendHorizonal />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||
<div className="flex gap-1">
|
||||
<Button className="w-9 h-9! p-0" variant="ghost">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
"mtp": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tauri-plugin-app-events-api": "^0.2.0",
|
||||
"zod": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { onResume } from "tauri-plugin-app-events-api";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { MTPClient } from "mtp";
|
||||
import { type z } from "zod";
|
||||
import { ConnectionState } from "mtp";
|
||||
|
|
@ -92,10 +92,6 @@ type ContextType = {
|
|||
|
||||
const MTPContext = createContext<ContextType | undefined>(undefined);
|
||||
|
||||
function isTauriMobile() {
|
||||
return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function getProtocolErrorDetails(error: unknown) {
|
||||
if (typeof error !== "object" || error === null || !("type" in error)) {
|
||||
return null;
|
||||
|
|
@ -143,7 +139,21 @@ function validateResponse<T extends keyof Schemas & string>(
|
|||
} as ProtocolMessage<T>;
|
||||
}
|
||||
|
||||
export function Provider(props: {
|
||||
function useMessageHandlers() {
|
||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||
const subscribePush = useCallback((handler: PushHandler) => {
|
||||
pushHandlersRef.current.add(handler);
|
||||
return () => pushHandlersRef.current.delete(handler);
|
||||
}, []);
|
||||
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||
interceptorsRef.current.add(interceptor);
|
||||
return () => interceptorsRef.current.delete(interceptor);
|
||||
}, []);
|
||||
return { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush };
|
||||
}
|
||||
|
||||
function BrowserProvider(props: {
|
||||
children: ReactNode;
|
||||
blockConnection?: boolean;
|
||||
}) {
|
||||
|
|
@ -162,8 +172,8 @@ export function Provider(props: {
|
|||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||
null,
|
||||
);
|
||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||
useMessageHandlers();
|
||||
|
||||
const connected = readyState === ConnectionState.Connected;
|
||||
|
||||
|
|
@ -203,16 +213,6 @@ export function Provider(props: {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const subscribePush = useCallback((handler: PushHandler) => {
|
||||
pushHandlersRef.current.add(handler);
|
||||
return () => pushHandlersRef.current.delete(handler);
|
||||
}, []);
|
||||
|
||||
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||
interceptorsRef.current.add(interceptor);
|
||||
return () => interceptorsRef.current.delete(interceptor);
|
||||
}, []);
|
||||
|
||||
// Reconnect stuff
|
||||
const resolveConnectionRef = useRef(() => {});
|
||||
useEffect(() => {
|
||||
|
|
@ -223,7 +223,6 @@ export function Provider(props: {
|
|||
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectScheduled = false;
|
||||
let disposed = false;
|
||||
let resumeListenerRegistered = false;
|
||||
let connectionGeneration = 0;
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
|
|
@ -292,8 +291,6 @@ export function Provider(props: {
|
|||
setIdentified(false);
|
||||
setIdentifying(false);
|
||||
|
||||
await MTPClient.init();
|
||||
|
||||
const [userId, keyring] = await Promise.all([
|
||||
load("user_id"),
|
||||
load("mtp_keyring"),
|
||||
|
|
@ -526,37 +523,13 @@ export function Provider(props: {
|
|||
}
|
||||
}
|
||||
|
||||
async function reconnectAfterResume() {
|
||||
if (disposed) return;
|
||||
|
||||
connectionGeneration += 1;
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
clearReconnectTimer();
|
||||
clearReconnectResetTimer();
|
||||
attempts = 0;
|
||||
reconnectScheduled = false;
|
||||
await connect();
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
if (!props.blockConnection && isTauriMobile()) {
|
||||
resumeListenerRegistered = true;
|
||||
onResume(() => {
|
||||
void reconnectAfterResume();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearReconnectTimer();
|
||||
clearReconnectResetTimer();
|
||||
|
||||
if (resumeListenerRegistered) {
|
||||
onResume();
|
||||
}
|
||||
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
setReadyState(ConnectionState.Disconnected);
|
||||
|
|
@ -564,7 +537,7 @@ export function Provider(props: {
|
|||
setIdentifying(false);
|
||||
sonnerToast.dismiss("mtp-connection-toast");
|
||||
};
|
||||
}, [mtpUrl, props.blockConnection, load]);
|
||||
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
|
||||
|
||||
// No Iota check
|
||||
useEffect(() => {
|
||||
|
|
@ -626,7 +599,7 @@ export function Provider(props: {
|
|||
}
|
||||
return response;
|
||||
},
|
||||
[mtpRef],
|
||||
[interceptorsRef, mtpRef],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -650,6 +623,235 @@ export function Provider(props: {
|
|||
);
|
||||
}
|
||||
|
||||
type NativeSnapshot = {
|
||||
generation: number;
|
||||
readyState: number;
|
||||
identified: boolean;
|
||||
state?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type NativeEvent =
|
||||
| { kind: "state"; snapshot: NativeSnapshot }
|
||||
| { kind: "message"; generation: number; message: unknown }
|
||||
| {
|
||||
kind: "log";
|
||||
level: number;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
function TauriProvider(props: {
|
||||
children: ReactNode;
|
||||
blockConnection?: boolean;
|
||||
}) {
|
||||
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
|
||||
generation: 0,
|
||||
readyState: ConnectionState.Disconnected,
|
||||
identified: false,
|
||||
});
|
||||
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||
const generationRef = useRef(0);
|
||||
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||
useMessageHandlers();
|
||||
const subscriptionsRef = useRef(
|
||||
new Map<string, Set<(message: ProtocolMessage) => void>>(),
|
||||
);
|
||||
|
||||
const applySnapshot = useCallback((next: NativeSnapshot) => {
|
||||
if (next.generation < generationRef.current) return;
|
||||
generationRef.current = next.generation;
|
||||
if (next.error) {
|
||||
log(0, "android", "orange", "MTP connection failed", next.error);
|
||||
}
|
||||
setSnapshot(next);
|
||||
if (!next.identified || next.state === undefined) return;
|
||||
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
|
||||
if (!parsed.success) {
|
||||
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
|
||||
return;
|
||||
}
|
||||
setFreshContacts(parsed.data.Contacts);
|
||||
setFreshCommunities(parsed.data.Communities);
|
||||
setFreshCalls(parsed.data.Calls);
|
||||
}, []);
|
||||
|
||||
const dispatchMessage = useCallback(
|
||||
(raw: unknown) => {
|
||||
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
|
||||
const message = raw as { id?: number; type: string; data: unknown };
|
||||
let validated: ProtocolMessage;
|
||||
try {
|
||||
validated = validateResponse(
|
||||
message.type as keyof Schemas & string,
|
||||
message,
|
||||
);
|
||||
} catch (error) {
|
||||
log(1, "mtp", "red", "Failed to validate native MTP message", error);
|
||||
return;
|
||||
}
|
||||
for (const handler of subscriptionsRef.current.get(validated.type) ??
|
||||
[]) {
|
||||
handler(validated);
|
||||
}
|
||||
if (!(PUSH_TYPES as readonly string[]).includes(validated.type)) return;
|
||||
for (const handler of [...pushHandlersRef.current]) {
|
||||
void Promise.resolve(handler(validated)).catch((error) => {
|
||||
log(1, "mtp", "red", "Native MTP push handler failed", error, {
|
||||
type: validated.type,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[pushHandlersRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.blockConnection) return;
|
||||
let disposed = false;
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
void (async () => {
|
||||
unlisten = await listen<NativeEvent>("mtp://event", ({ payload }) => {
|
||||
if (disposed) return;
|
||||
if (payload.kind === "state") {
|
||||
applySnapshot(payload.snapshot);
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "message") {
|
||||
if (payload.generation === generationRef.current) {
|
||||
dispatchMessage(payload.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
log(
|
||||
payload.level,
|
||||
"android",
|
||||
"orange",
|
||||
payload.message,
|
||||
payload.details,
|
||||
);
|
||||
});
|
||||
const current = await invoke<NativeSnapshot>("mtp_status");
|
||||
if (!disposed) applySnapshot(current);
|
||||
})().catch((error) => {
|
||||
log(0, "mtp", "red", "Failed to initialize native MTP bridge", error);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [applySnapshot, dispatchMessage, props.blockConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.blockConnection) return;
|
||||
const updateVisibility = () => {
|
||||
void invoke("mtp_set_ui_visible", {
|
||||
visible: document.visibilityState === "visible" && document.hasFocus(),
|
||||
});
|
||||
};
|
||||
updateVisibility();
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
window.addEventListener("focus", updateVisibility);
|
||||
window.addEventListener("blur", updateVisibility);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
window.removeEventListener("focus", updateVisibility);
|
||||
window.removeEventListener("blur", updateVisibility);
|
||||
void invoke("mtp_set_ui_visible", { visible: false });
|
||||
};
|
||||
}, [props.blockConnection]);
|
||||
|
||||
const send = useCallback<BoundSendFn>(
|
||||
async (type, data, options) => {
|
||||
const response = await invoke<ProtocolMessage>("mtp_request", {
|
||||
typeName: type,
|
||||
data: data ?? {},
|
||||
id: options?.id,
|
||||
});
|
||||
const validated = validateResponse(type, response);
|
||||
for (const interceptor of interceptorsRef.current) {
|
||||
void Promise.resolve(
|
||||
interceptor({ type, data, response: validated as ProtocolMessage }),
|
||||
).catch((error) => {
|
||||
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
|
||||
});
|
||||
}
|
||||
return validated;
|
||||
},
|
||||
[interceptorsRef],
|
||||
);
|
||||
|
||||
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
|
||||
const handlers =
|
||||
subscriptionsRef.current.get(type) ??
|
||||
new Set<(message: ProtocolMessage) => void>();
|
||||
handlers.add(handler as (message: ProtocolMessage) => void);
|
||||
subscriptionsRef.current.set(type, handlers);
|
||||
return () => {
|
||||
handlers.delete(handler as (message: ProtocolMessage) => void);
|
||||
if (handlers.size === 0) subscriptionsRef.current.delete(type);
|
||||
};
|
||||
}, []);
|
||||
const connected = snapshot.readyState === ConnectionState.Connected;
|
||||
const contextReady = connected && snapshot.identified;
|
||||
|
||||
return (
|
||||
<MTPContext.Provider
|
||||
value={{
|
||||
send,
|
||||
subscribe,
|
||||
subscribePush,
|
||||
addInterceptor,
|
||||
readyState: snapshot.readyState,
|
||||
identified: snapshot.identified,
|
||||
freshContacts,
|
||||
freshCommunities,
|
||||
freshCalls,
|
||||
contextReady,
|
||||
loadingDescription: connected
|
||||
? "Waiting for authenticated session"
|
||||
: "Establishing native transport channel",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</MTPContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Provider(props: {
|
||||
children: ReactNode;
|
||||
blockConnection?: boolean;
|
||||
}) {
|
||||
const [wasmReady, setWasmReady] = useState(false);
|
||||
const [wasmError, setWasmError] = useState<unknown>();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void MTPClient.init().then(
|
||||
() => {
|
||||
if (active) setWasmReady(true);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (active) setWasmError(() => error);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (wasmError) throw wasmError;
|
||||
if (!wasmReady) return null;
|
||||
|
||||
return isTauri() ? (
|
||||
<TauriProvider {...props} />
|
||||
) : (
|
||||
<BrowserProvider {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function useMTP(): ContextType {
|
||||
const context = useContext(MTPContext);
|
||||
if (!context) {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const user = await get(data.SenderId);
|
||||
|
||||
if (isTauri()) {
|
||||
if (!appFocused) return;
|
||||
const permissionGranted =
|
||||
(await isTauriNotificationPermissionGranted()) ||
|
||||
(await requestTauriNotificationPermission()) === "granted";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import {
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import LegalPage from "./pages/legal";
|
||||
import { onboardingSteps } from "./steps";
|
||||
import TauriPermissionsPage from "./pages/tauriPermissions";
|
||||
|
||||
export {
|
||||
useOnboardingStep,
|
||||
|
|
@ -27,6 +29,7 @@ interface GateState {
|
|||
acceptedTOS: boolean;
|
||||
includeLegal: boolean;
|
||||
includeOnboarding: boolean;
|
||||
includeTauriPermissions: boolean;
|
||||
}
|
||||
|
||||
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
|
|
@ -72,12 +75,14 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
acceptedTOS,
|
||||
onboardingDone,
|
||||
onboardingStarted,
|
||||
tauriPermissionsDone,
|
||||
] = await Promise.all([
|
||||
load("legal_docs"),
|
||||
load("accepted_privacy_policy"),
|
||||
load("accepted_terms_of_service"),
|
||||
load("onboarding_done"),
|
||||
load("onboarding_started"),
|
||||
load("tauri_permissions_done"),
|
||||
]);
|
||||
|
||||
if (!active) return;
|
||||
|
|
@ -104,6 +109,10 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
acceptedTOS: currentAcceptedTOS,
|
||||
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||
includeOnboarding,
|
||||
includeTauriPermissions:
|
||||
isTauri() &&
|
||||
/Android/.test(navigator.userAgent) &&
|
||||
!tauriPermissionsDone,
|
||||
});
|
||||
} catch (caught) {
|
||||
if (!active) return;
|
||||
|
|
@ -142,8 +151,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
save("onboarding_started", false),
|
||||
]);
|
||||
}
|
||||
if (state?.includeTauriPermissions) {
|
||||
await save("tauri_permissions_done", true);
|
||||
}
|
||||
setComplete(true);
|
||||
}, [save, state?.includeOnboarding]);
|
||||
}, [save, state?.includeOnboarding, state?.includeTauriPermissions]);
|
||||
|
||||
if (error && errorDescription) {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
|
|
@ -174,6 +186,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
if (state.includeOnboarding) {
|
||||
steps.push(...onboardingSteps(onboardingThemeId, setOnboardingThemeId));
|
||||
}
|
||||
if (state.includeTauriPermissions) {
|
||||
steps.push({
|
||||
id: "tauri-permissions",
|
||||
title: "Enable notifications",
|
||||
description:
|
||||
"We need these permissions so that notifications can be independent of Google Play Services.",
|
||||
defaultCanContinue: false,
|
||||
content: <TauriPermissionsPage />,
|
||||
});
|
||||
}
|
||||
|
||||
if (complete || steps.length === 0) return <>{children}</>;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export default function LegalPage({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-10 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<BigCheckbox
|
||||
|
|
|
|||
99
packages/onboarding/src/pages/tauriPermissions.tsx
Normal file
99
packages/onboarding/src/pages/tauriPermissions.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Button, useOnboardingStep } from "@methanium/ui";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { BatteryCharging, Bell } from "lucide-react";
|
||||
|
||||
export default function TauriPermissionsPage() {
|
||||
const [notificationsGranted, setNotificationsGranted] = useState(false);
|
||||
const [batteryExempt, setBatteryExempt] = useState(false);
|
||||
const [notificationAttempted, setNotificationAttempted] = useState(false);
|
||||
const [batteryAttempted, setBatteryAttempted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
void Promise.all([
|
||||
isPermissionGranted(),
|
||||
invoke<boolean>("mtp_is_ignoring_battery_optimizations"),
|
||||
]).then(([notifications, battery]) => {
|
||||
setNotificationsGranted(notifications);
|
||||
setBatteryExempt(battery);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
const interval = window.setInterval(refresh, 1_000);
|
||||
return () => {
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useOnboardingStep({
|
||||
canContinue:
|
||||
(notificationsGranted || notificationAttempted) &&
|
||||
(batteryExempt || batteryAttempted),
|
||||
onContinue: async () => {
|
||||
await invoke("mtp_set_enabled", { enabled: true });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-12 py-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<Bell className="h-6! w-6!" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Allow notifications</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Allow Tensamin to notify you while it's closed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={notificationsGranted ? "outline" : "default"}
|
||||
disabled={notificationsGranted}
|
||||
onClick={() => {
|
||||
setNotificationAttempted(true);
|
||||
void requestPermission().then((permission) => {
|
||||
setNotificationsGranted(permission === "granted");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{notificationsGranted ? "Allowed" : "Allow notifications"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<BatteryCharging className="h-6! w-6!" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Allow running in the background</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Exclude Tensamin from battery optimisation so Android does not
|
||||
suspend it's connection for decryption of live messages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={batteryExempt ? "outline" : "default"}
|
||||
disabled={batteryExempt}
|
||||
onClick={() => {
|
||||
setBatteryAttempted(true);
|
||||
void invoke("mtp_request_battery_exemption");
|
||||
}}
|
||||
>
|
||||
{batteryExempt ? "Allowed" : "Open battery prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -454,6 +454,7 @@ export interface Storage extends SettingsStorageDefaults {
|
|||
mtp_keyring: string;
|
||||
onboarding_done: boolean;
|
||||
onboarding_started: boolean;
|
||||
tauri_permissions_done: boolean;
|
||||
ppandtos_done: boolean;
|
||||
accepted_terms_of_service: boolean;
|
||||
accepted_privacy_policy: boolean;
|
||||
|
|
@ -496,6 +497,7 @@ export const storageDefaults: Storage = {
|
|||
mtp_keyring: "",
|
||||
onboarding_done: false,
|
||||
onboarding_started: false,
|
||||
tauri_permissions_done: false,
|
||||
ppandtos_done: false,
|
||||
accepted_terms_of_service: false,
|
||||
accepted_privacy_policy: false,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function log(
|
|||
| "red"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "orange"
|
||||
| "purple"
|
||||
| "blue"
|
||||
| "cyan"
|
||||
|
|
@ -32,6 +33,7 @@ export function log(
|
|||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
orange: "\x1b[38;5;208m",
|
||||
purple: "\x1b[35m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "@tensamin/shared/indexedDb";
|
||||
import { ErrorScreen } from "@methanium/ui";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import {
|
||||
decodeSecureValue,
|
||||
encodeSecureValue,
|
||||
|
|
@ -94,6 +95,14 @@ export default function StorageProvider(props: { children: ReactNode }) {
|
|||
const generation = generations.current.get(key) ?? 0;
|
||||
const request = (async () => {
|
||||
try {
|
||||
if (key === "mtp_keyring" && isTauri()) {
|
||||
const nativeValue = await invoke<string | null>("mtp_load_keyring");
|
||||
const value = (nativeValue ?? defaults[key]) as StorageSchema[K];
|
||||
if ((generations.current.get(key) ?? 0) === generation) {
|
||||
commit(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||
const desktopStatus = desktopStorage?.getStatus
|
||||
? await desktopStorage.getStatus()
|
||||
|
|
@ -143,6 +152,10 @@ export default function StorageProvider(props: { children: ReactNode }) {
|
|||
options: SaveOptions = {},
|
||||
): Promise<void> => {
|
||||
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
||||
if (key === "mtp_keyring" && isTauri()) {
|
||||
commit(key, value);
|
||||
return;
|
||||
}
|
||||
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||
if (desktopStorage?.delete)
|
||||
|
|
|
|||
8686
pnpm-lock.yaml
generated
8686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue