diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1e9801..d834abc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,10 @@ android { excludes += "META-INF/INDEX.LIST" excludes += "META-INF/*.properties" } + jniLibs { + // 禁用 debug 版本的 JNI 符号剥离(规避 NDK llvm-strip 在非 x86_64 环境下的启动失败问题 + keepDebugSymbols += "**/*.so" + } } } @@ -97,9 +101,12 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation(libs.kotlinx.coroutines.android) implementation("no.nordicsemi.android.kotlin.ble:client:1.3.1") + implementation(libs.accompanist.permissions) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/app/src/main/java/moe/reimu/catshare/MainActivity.kt b/app/src/main/java/moe/reimu/catshare/MainActivity.kt index c51c1a3..35517d0 100644 --- a/app/src/main/java/moe/reimu/catshare/MainActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/MainActivity.kt @@ -1,331 +1,100 @@ package moe.reimu.catshare import android.Manifest -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.launch -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import moe.reimu.catshare.services.GattServerService -import moe.reimu.catshare.ui.DefaultCard +import moe.reimu.catshare.ui.navigation.CatNavHost import moe.reimu.catshare.ui.theme.CatShareTheme -import moe.reimu.catshare.utils.ServiceState -import moe.reimu.catshare.utils.TAG -import moe.reimu.catshare.utils.registerInternalBroadcastReceiver -import rikka.shizuku.Shizuku -import java.util.ArrayList class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - checkAndRequestPermissions() + private val fileLauncher = registerForActivityResult(ChooseFilesContract()) { uris -> + if (uris.isNotEmpty()) { + val intent = Intent(this, ShareActivity::class.java) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + startActivity(intent) + } + } + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + // 任何被拒绝的权限都会结束 Activity(沿用原逻辑) + if (results.any { !it.value }) { + finish() + } + } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val permissions = collectPermissions() enableEdgeToEdge() setContent { CatShareTheme { - MainActivityContent() + Surface(modifier = Modifier.fillMaxSize()) { + CatNavHost(onPickFiles = { fileLauncher.launch(null) }) + } } } + if (permissions.isNotEmpty()) { + permissionLauncher.launch(permissions.toTypedArray()) + } } - private fun checkAndRequestPermissions() { - val permissionsToRequest = mutableListOf() - - if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( + private fun collectPermissions(): List { + val out = mutableListOf() + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission( this, Manifest.permission.NEARBY_WIFI_DEVICES ) != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(Manifest.permission.NEARBY_WIFI_DEVICES) + out.add(Manifest.permission.NEARBY_WIFI_DEVICES) } - - if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS) + out.add(Manifest.permission.POST_NOTIFICATIONS) } - - if (Build.VERSION.SDK_INT <= 32 && ContextCompat.checkSelfPermission( + if (Build.VERSION.SDK_INT <= 32 && + ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION) + out.add(Manifest.permission.ACCESS_FINE_LOCATION) } - if (Build.VERSION.SDK_INT >= 31) { for (perm in listOf( Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT )) { - if (ContextCompat.checkSelfPermission( - this, perm - ) != PackageManager.PERMISSION_GRANTED + if (ContextCompat.checkSelfPermission(this, perm) + != PackageManager.PERMISSION_GRANTED ) { - permissionsToRequest.add(perm) + out.add(perm) } } } - - if (permissionsToRequest.isNotEmpty()) { - requestPermissions(permissionsToRequest.toTypedArray(), 0) - } - } - - override fun onRequestPermissionsResult( - requestCode: Int, permissions: Array, grantResults: IntArray, deviceId: Int - ) { - for ((name, status) in permissions.zip(grantResults.toList())) { - if (status == PackageManager.PERMISSION_GRANTED) { - continue - } - - Toast.makeText(this, "$name not granted", Toast.LENGTH_LONG).show() - finish() - - return - } + return out } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainActivityContent() { - var checked by remember { mutableStateOf(false) } - val listState = rememberLazyListState() - - val context = LocalContext.current - DisposableEffect(context) { - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == ServiceState.ACTION_UPDATE_RECEIVER_STATE) { - checked = intent.getBooleanExtra("isRunning", false) - } - } - } - - context.registerInternalBroadcastReceiver( - receiver, - IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE), - ) - context.sendBroadcast(ServiceState.getQueryIntent()) - - onDispose { - context.unregisterReceiver(receiver) - } - } - - val localMacAddressGranted = remember { - context.checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED - } - - var shizukuGranted by remember { - mutableStateOf(false) - } - - var shizukuAvailable by remember { - mutableStateOf(false) - } - - DisposableEffect(Unit) { - val permissionListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> - Log.d(TAG, "Shizuku grant result: $grantResult") - shizukuGranted = grantResult == PackageManager.PERMISSION_GRANTED - } - - val binderRecvListener = Shizuku.OnBinderReceivedListener { - shizukuAvailable = true - shizukuGranted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - } - - val binderDeadReceiver = Shizuku.OnBinderDeadListener { - shizukuAvailable = false - } - - Shizuku.addRequestPermissionResultListener(permissionListener) - Shizuku.addBinderReceivedListenerSticky(binderRecvListener) - Shizuku.addBinderDeadListener(binderDeadReceiver) - - onDispose { - Shizuku.removeRequestPermissionResultListener(permissionListener) - Shizuku.removeBinderReceivedListener(binderRecvListener) - Shizuku.removeBinderDeadListener(binderDeadReceiver) - } - } - - val pickFilesLauncher = rememberLauncherForActivityResult(ChooseFilesContract()) { pickedUris -> - if (pickedUris.isNotEmpty()) { - val intent = Intent(context, ShareActivity::class.java) - .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(pickedUris)) - context.startActivity(intent) - } - } - - Scaffold(topBar = { - TopAppBar(title = { Text(text = stringResource(R.string.app_name)) }, actions = { - IconButton(onClick = { - context.startActivity(Intent(context, SettingsActivity::class.java)) - }) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = stringResource(R.string.title_activity_settings) - ) - } - }) - }) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item { - DefaultCard(onClick = { - pickFilesLauncher.launch() - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MyIcon(Icons.Filled.Share) - Column { - Text( - text = stringResource(R.string.send), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.send_desc), - ) - } - } - } - - } - item { - DefaultCard { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MyIcon(ImageVector.vectorResource(R.drawable.ic_bluetooth_searching)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.discoverable), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.discoverable_desc), - ) - } - Switch(checked = checked, onCheckedChange = { - if (it) { - GattServerService.start(context) - } else { - GattServerService.stop(context) - } - }, modifier = Modifier.padding(start = 8.dp)) - } - } - } - - if (!localMacAddressGranted) { - item { - DefaultCard(onClick = { - if (!shizukuGranted) { - try { - Shizuku.requestPermission(0) - } catch (e: Throwable) { - e.printStackTrace() - } - } - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MyIcon( - if (shizukuAvailable && shizukuGranted) { - ImageVector.vectorResource(R.drawable.ic_done) - } else { - ImageVector.vectorResource(R.drawable.ic_close) - } - ) - Column { - Text( - text = stringResource( - if (shizukuAvailable) { - if (shizukuGranted) { - R.string.shizuku_available - } else { - R.string.shizuku_not_granted - } - } else { - R.string.shizuku_unavailable - } - ), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.shizuku_desc), - ) - } - } - } - } - } - } - } -} - - class ChooseFilesContract : ActivityResultContract>() { - override fun createIntent(context: Context, input: Void?): Intent { + override fun createIntent(context: android.content.Context, input: Void?): Intent { val cf = Intent(Intent.ACTION_GET_CONTENT) .setType("*/*") .addCategory(Intent.CATEGORY_OPENABLE) @@ -334,42 +103,21 @@ class ChooseFilesContract : ActivityResultContract>() { } override fun getSynchronousResult( - context: Context, - input: Void? - ): SynchronousResult>? = - null + context: android.content.Context, + input: Void?, + ): ActivityResultContract.SynchronousResult>? = null override fun parseResult(resultCode: Int, intent: Intent?): List { - if (intent == null) { - return emptyList() - } - + if (intent == null) return emptyList() val ret = mutableListOf() - val clipData = intent.clipData if (clipData != null) { - for (i in 0.. - val listState = rememberLazyListState() - - LazyColumn( - modifier = Modifier.padding(innerPadding), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item { - DefaultCard { - Column(modifier = Modifier.padding(16.dp)) { - OutlinedTextField( - value = deviceNameValue, - onValueChange = { deviceNameValue = it }, - label = { Text(stringResource(R.string.device_name)) }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - item { - DefaultCard { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.verbose_name), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.weight(1.0f)) - Switch(checked = verboseValue, onCheckedChange = { - verboseValue = it - }) - } - } - } - item { - DefaultCard { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.auto_accept_name), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.weight(1.0f)) - Switch(checked = autoAcceptValue, onCheckedChange = { - autoAcceptValue = it - }) - } - } - } - item { - DefaultCard(onClick = { - Thread { - try { - val context = MyApplication.getInstance() - val logDir = File(context.cacheDir, "logs") - logDir.mkdirs() - val logFile = File(logDir, "logcat.txt") - - logFile.outputStream().use { - val proc = Runtime.getRuntime().exec("logcat -d") - try { - proc.inputStream.copyTo(it) - } finally { - proc.destroy() - } - } - val uri = FileProvider.getUriForFile( - context, - "${BuildConfig.APPLICATION_ID}.fileProvider", - logFile - ) - val intent = Intent(Intent.ACTION_SEND) - .putExtra(Intent.EXTRA_STREAM, uri) - .setType("text/plain") - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } catch (e: Exception) { - Log.e("LogcatCapture", "Failed to save logs", e) - Handler(Looper.getMainLooper()).post { - Toast.makeText( - context, - context.getString(R.string.log_capture_failed), - Toast.LENGTH_LONG - ).show() - } - } - }.start() - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(R.string.capture_logs), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.capture_logs_desc), - ) - } - } - } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt index 6bd7abd..901da8e 100644 --- a/app/src/main/java/moe/reimu/catshare/ShareActivity.kt +++ b/app/src/main/java/moe/reimu/catshare/ShareActivity.kt @@ -1,130 +1,87 @@ package moe.reimu.catshare -import android.annotation.SuppressLint import android.bluetooth.BluetoothManager -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import android.content.ComponentName -import android.content.Context import android.content.Intent -import android.content.ServiceConnection import android.net.Uri import android.net.wifi.WifiManager import android.os.Bundle -import android.os.IBinder -import android.os.ParcelUuid import android.provider.MediaStore import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LifecycleStartEffect -import moe.reimu.catshare.models.DiscoveredDevice import moe.reimu.catshare.models.FileInfo -import moe.reimu.catshare.models.TaskInfo -import moe.reimu.catshare.services.P2pSenderService -import moe.reimu.catshare.ui.DefaultCard import moe.reimu.catshare.ui.theme.CatShareTheme -import moe.reimu.catshare.utils.BleUtils -import moe.reimu.catshare.utils.DeviceUtils -import moe.reimu.catshare.utils.NotificationUtils -import moe.reimu.catshare.utils.ShizukuUtils -import moe.reimu.catshare.utils.TAG -import java.nio.ByteBuffer -import kotlin.random.Random +import moe.reimu.catshare.ui.screens.ShareScreen class ShareActivity : ComponentActivity() { - private lateinit var bluetoothManager: BluetoothManager - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - bluetoothManager = getSystemService(BluetoothManager::class.java) - val adapter = bluetoothManager.adapter - if (adapter == null || !adapter.isEnabled) { - NotificationUtils.showBluetoothToast(this) + val btManager = getSystemService(BluetoothManager::class.java) + val btAdapter = btManager.adapter + if (btAdapter == null || !btAdapter.isEnabled) { + Toast.makeText(this, R.string.bluetooth_disabled, Toast.LENGTH_LONG).show() finish() return } - val wifiManager = getSystemService(WifiManager::class.java) if (!wifiManager.isWifiEnabled) { - NotificationUtils.showWifiToast(this) + Toast.makeText(this, R.string.wifi_disabled, Toast.LENGTH_LONG).show() finish() return } - val fileInfos = try { - if (intent.action == Intent.ACTION_SEND) { - @Suppress("DEPRECATION") val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) - if (uri != null) { - listOf(uri).mapNotNull { extractFileInfo(it) } - } else { - val text = intent.getStringExtra(Intent.EXTRA_TEXT) - listOf( - FileInfo( - Uri.EMPTY, "", "", 0, text - ) - ) - } - } else { - @Suppress("DEPRECATION") val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) - uris?.mapNotNull { extractFileInfo(it) } ?: emptyList() - } + val files = try { + parseSharedFiles(intent) } catch (e: Throwable) { - Log.e("ShareActivity", "Failed to extract file info", e) - Toast.makeText(this, R.string.no_file_shared, Toast.LENGTH_SHORT).show() - finish() - return + Log.e("ShareActivity", "Failed to parse files", e) + emptyList() } - - if (fileInfos.isEmpty()) { + if (files.isEmpty()) { Toast.makeText(this, R.string.no_file_shared, Toast.LENGTH_SHORT).show() finish() return } - Log.i(TAG, "Shared ${fileInfos.size} files") - - ShizukuUtils.bindService() - enableEdgeToEdge() setContent { CatShareTheme { - ShareActivityContent(fileInfos) + Surface(modifier = Modifier.fillMaxSize()) { + ShareScreen( + files = files, + onBack = { finish() }, + onDeviceSelected = { + finish() + }, + ) + } + } + } + } + + private fun parseSharedFiles(intent: Intent): List { + return if (intent.action == Intent.ACTION_SEND) { + @Suppress("DEPRECATION") + val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (uri != null) { + listOfNotNull(extractFileInfo(uri)) + } else { + val text = intent.getStringExtra(Intent.EXTRA_TEXT) + if (text != null) { + listOf(FileInfo(Uri.EMPTY, "", "", 0, text)) + } else { + emptyList() + } } + } else { + @Suppress("DEPRECATION") + val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + uris?.mapNotNull { extractFileInfo(it) } ?: emptyList() } } @@ -133,210 +90,20 @@ class ShareActivity : ComponentActivity() { val proj = arrayOf( MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, - MediaStore.MediaColumns.SIZE + MediaStore.MediaColumns.SIZE, ) - return cr.query(uri, proj, null, null)?.use { + return cr.query(uri, proj, null, null, null)?.use { if (it.moveToFirst()) { - val mimeIndex = it.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) + val mimeIdx = it.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) FileInfo( - uri, - it.getString(it.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)), - if (mimeIndex < 0) { - "application/octet-stream" - } else { - it.getString(mimeIndex) - }, - it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), - null + uri = uri, + name = it.getString(it.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)), + mimeType = if (mimeIdx < 0) "application/octet-stream" + else it.getString(mimeIdx) ?: "application/octet-stream", + size = it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), + textContent = null, ) - } else { - null - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ShareActivityContent(files: List) { - val context = LocalContext.current - val discoveredDevices = deviceScanner() - - val listState = rememberLazyListState() - val iconMod = Modifier - .size(48.dp) - .padding(end = 16.dp) - - Scaffold( - topBar = { - TopAppBar(title = { Text(text = stringResource(R.string.choose_recipient)) }) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - if (discoveredDevices.isEmpty()) { - item { - Text(stringResource(R.string.scanning_desc)) - } - } else { - items(discoveredDevices, key = { it.id }) { - DefaultCard(onClick = { - val task = TaskInfo( - id = Random.nextInt(), - device = it, - files = files - ) - P2pSenderService.startTaskChecked(context, task) - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = null, - modifier = iconMod - ) - Column { - Text( - text = if (BuildConfig.DEBUG) { - "${it.name} (${it.id}, ${it.device.address})" - } else { - it.name - }, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = it.brand ?: stringResource(R.string.unknown) - ) - } - } - } - } - } + } else null } } } - -@SuppressLint("MissingPermission") -@Composable -fun deviceScanner(): List { - val context = LocalContext.current - var discoveredDevices by remember { mutableStateOf(emptyList()) } - - LifecycleResumeEffect(context) { - val manager = context.getSystemService(BluetoothManager::class.java) - val adapter = manager.adapter - val devicesLock = Object() - - val callback = object : ScanCallback() { - override fun onScanFailed(errorCode: Int) { - println() - } - - override fun onScanResult(callbackType: Int, result: ScanResult) { - val record = result.scanRecord ?: return - var supports5Ghz = false - var deviceName: String? = null - var brandId: Byte? = null - var senderId: String? = null - - for ((uuid, data) in record.serviceData.entries) { - when (data.size) { - 6 -> { - // UUID contains brand and 5GHz flag - val buf = ByteBuffer.allocate(16) - buf.putLong(uuid.uuid.mostSignificantBits) - buf.putLong(uuid.uuid.leastSignificantBits) - val arr = buf.array() - supports5Ghz = arr[2].toInt() == 1 - brandId = arr[3] - } - - 27 -> { - // Data contains device name and ID - val nameBuf = mutableListOf() - for (i in 10..25) { - if (data[i].toInt() != 0) { - nameBuf.add(data[i]) - } else { - break - } - } - - val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt()) - senderId = String.format("%04x", senderIdRaw) - - var name = nameBuf.toByteArray().decodeToString() - if (name.last() == '\t') { - name = name.removeSuffix("\t") + "..." - } - deviceName = name - } - } - } - - if (deviceName == null || senderId == null) { - return - } - - val brand = brandId?.let { - DeviceUtils.deviceNameById(it) - } - - val newDevice = DiscoveredDevice( - result.device, senderId, deviceName, brand, supports5Ghz - ) - var replaced = false - synchronized(devicesLock) { - val newList = discoveredDevices.map { - if (it.id == senderId) { - replaced = true - newDevice - } else { - it - } - }.toMutableList() - if (!replaced) { - newList.add(newDevice) - } - discoveredDevices = newList - } - } - } - - var startedScanner: BluetoothLeScanner? = null - - if (adapter != null) { - val scanner = adapter.bluetoothLeScanner - val filters = listOf( - ScanFilter.Builder().setServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID)).build() - ) - val settings = - ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() - - try { - scanner.startScan(filters, settings, callback) - startedScanner = scanner - Log.d(TAG, "Started scanning") - } catch (e: SecurityException) { - Log.e(TAG, "Failed to start scan", e) - } - } - - onPauseOrDispose { - try { - startedScanner?.stopScan(callback) - Log.d(TAG, "Stopped scanning") - } catch (e: Exception) { - Log.e(TAG, "Failed to stop scan", e) - } - } - } - - return discoveredDevices -} \ No newline at end of file diff --git a/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt b/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt index 99f1412..b50be7e 100644 --- a/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt +++ b/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import moe.reimu.catshare.AppSettings import moe.reimu.catshare.BleSecurity @@ -95,7 +94,7 @@ class P2pSenderService : BaseP2pService() { private val currentTaskLock = Object() private var currentJob: Job? = null - private var curreentTaskId: Int? = null + private var currentTaskId: Int? = null private lateinit var notificationManager: NotificationManagerCompat @@ -545,14 +544,14 @@ class P2pSenderService : BaseP2pService() { stopForeground(STOP_FOREGROUND_REMOVE) MyApplication.getInstance().clearBusy() synchronized(currentTaskLock) { - curreentTaskId = null + currentTaskId = null currentJob = null } } } synchronized(currentTaskLock) { - curreentTaskId = task.id + currentTaskId = task.id currentJob = job } @@ -561,7 +560,7 @@ class P2pSenderService : BaseP2pService() { fun cancel(taskId: Int) { synchronized(currentTaskLock) { - if (curreentTaskId == taskId) { + if (currentTaskId == taskId) { currentJob?.cancel(CancelledByUserException(false)) } } diff --git a/app/src/main/java/moe/reimu/catshare/ui/Cards.kt b/app/src/main/java/moe/reimu/catshare/ui/Cards.kt deleted file mode 100644 index 77486ca..0000000 --- a/app/src/main/java/moe/reimu/catshare/ui/Cards.kt +++ /dev/null @@ -1,31 +0,0 @@ -package moe.reimu.catshare.ui - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun DefaultCard( - modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit -) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = modifier.fillMaxWidth(), content = content - ) -} - -@Composable -fun DefaultCard( - onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit -) { - Card( - onClick = onClick, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = modifier.fillMaxWidth(), content = content - ) -} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt new file mode 100644 index 0000000..ab99d63 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatCard.kt @@ -0,0 +1,172 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import moe.reimu.catshare.ui.theme.CatShareShapes + +enum class CatCardVariant { Elevated, Filled, Outlined, Clickable } + +@Composable +fun CatCard( + modifier: Modifier = Modifier, + variant: CatCardVariant = CatCardVariant.Filled, + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit, +) { + val clickMod = if (onClick != null) { + Modifier.clickable( + enabled = enabled, + onClick = onClick, + ) + } else { + Modifier + } + + when (variant) { + CatCardVariant.Elevated -> { + ElevatedCard( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.medium, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } + } + + CatCardVariant.Filled -> { + Card( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } + } + + CatCardVariant.Outlined -> { + OutlinedCard( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) { + content() + } + } + } + + CatCardVariant.Clickable -> { + var pressed by remember { mutableStateOf(false) } + val targetContainer: Color by animateColorAsState( + if (pressed) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "card_color", + ) + Card( + modifier = modifier.fillMaxWidth().clickable( + enabled = enabled, + onClick = { + pressed = true + onClick?.invoke() + }, + ), + shape = CatShareShapes.medium, + colors = CardDefaults.cardColors(containerColor = targetContainer), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .animateContentSize( + animationSpec = spring(stiffness = Spring.StiffnessLow), + ), + ) { + content() + } + } + } + } +} + +@Composable +fun CatHeroCard( + title: String, + subtitle: String?, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, +) { + val clickMod = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ElevatedCard( + modifier = modifier.fillMaxWidth().then(clickMod), + shape = CatShareShapes.large, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer), + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { + if (icon != null) { + Surface( + shape = CatShareShapes.medium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.12f), + modifier = Modifier.padding(bottom = 16.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + icon() + } + } + } + androidx.compose.material3.Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + if (subtitle != null) { + androidx.compose.material3.Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt new file mode 100644 index 0000000..4d64a6d --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatIcon.kt @@ -0,0 +1,50 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +enum class CatIconSize(val size: Dp, val padding: Dp) { + Small(40.dp, 8.dp), + Medium(48.dp, 10.dp), + Large(56.dp, 14.dp), +} + +@Composable +fun CatIcon( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + size: CatIconSize = CatIconSize.Medium, + containerColor: Color? = null, + contentColor: Color? = null, +) { + val actualContainerColor = containerColor ?: MaterialTheme.colorScheme.primaryContainer + val actualContentColor = contentColor ?: contentColorFor(actualContainerColor) + + Surface( + modifier = modifier.size(size.size), + shape = CircleShape, + color = actualContainerColor, + contentColor = actualContentColor, + ) { + Box(modifier = Modifier.padding(size.padding)) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.size(size.size - size.padding * 2), + ) + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt new file mode 100644 index 0000000..875ad45 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatPullRefresh.kt @@ -0,0 +1,42 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatPullRefresh( + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), + contentPadding: androidx.compose.foundation.layout.PaddingValues = + androidx.compose.foundation.layout.PaddingValues(16.dp), + content: LazyListScope.() -> Unit, +) { + val pullState = rememberPullToRefreshState() + + PullToRefreshBox( + state = pullState, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize(), + ) { + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp), + ) { + content() + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt new file mode 100644 index 0000000..e29e5cc --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/CatTopAppBar.kt @@ -0,0 +1,93 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatTopAppBar( + title: String, + modifier: Modifier = Modifier, + navigationIcon: ImageVector? = null, + onNavigationClick: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null, + showNavigation: Boolean = true, +) { + val actualScroll = scrollBehavior ?: TopAppBarDefaults.pinnedScrollBehavior( + rememberTopAppBarState() + ) + + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + }, + modifier = modifier, + navigationIcon = { + if (showNavigation && navigationIcon != null && onNavigationClick != null) { + IconButton(onClick = onNavigationClick) { + Icon( + imageVector = navigationIcon, + contentDescription = null, + ) + } + } + }, + actions = actions, + scrollBehavior = actualScroll, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatChildAppBar( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + backIcon: ImageVector? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + }, + modifier = modifier, + navigationIcon = { + IconButton(onClick = onBack) { + if (backIcon != null) { + Icon(imageVector = backIcon, contentDescription = null) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + ) +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt new file mode 100644 index 0000000..249472d --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/components/LoadingStates.kt @@ -0,0 +1,132 @@ +package moe.reimu.catshare.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Column( + modifier = modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = contentColorFor(MaterialTheme.colorScheme.surfaceVariant), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(72.dp).padding(18.dp), + ) + } + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (actionLabel != null && onAction != null) { + Spacer(Modifier.height(20.dp)) + TextButton(onClick = onAction) { + Text(text = actionLabel) + } + } + } +} + +@Composable +fun ScanningState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, +) { + val scale = remember { Animatable(1f) } + val alpha = remember { Animatable(0.55f) } + + LaunchedEffect(Unit) { + while (true) { + scale.animateTo(targetValue = 1.15f, animationSpec = tween(durationMillis = 900)) + scale.animateTo(targetValue = 1f, animationSpec = tween(durationMillis = 900)) + } + } + LaunchedEffect(Unit) { + while (true) { + alpha.animateTo(targetValue = 1f, animationSpec = tween(durationMillis = 900)) + alpha.animateTo(targetValue = 0.55f, animationSpec = tween(durationMillis = 900)) + } + } + + Column( + modifier = modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = alpha.value * 0.55f), + modifier = Modifier.size((120 * scale.value).dp), + ) {} + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + contentColor = contentColorFor(MaterialTheme.colorScheme.primary), + modifier = Modifier.size(72.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(72.dp).padding(18.dp), + ) + } + } + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + diff --git a/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt b/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt new file mode 100644 index 0000000..9f31c32 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/navigation/CatNavHost.kt @@ -0,0 +1,80 @@ +package moe.reimu.catshare.ui.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import moe.reimu.catshare.models.FileInfo +import moe.reimu.catshare.ui.screens.MainScreen +import moe.reimu.catshare.ui.screens.SettingsScreen +import moe.reimu.catshare.ui.screens.ShareScreen + +object Routes { + const val MAIN = "main" + const val SHARE = "share" + const val SETTINGS = "settings" +} + +@Composable +fun CatNavHost( + modifier: Modifier = Modifier, + onPickFiles: () -> Unit, + navController: NavHostController = rememberNavController(), + startDestination: String = Routes.MAIN, +) { + NavHost( + modifier = modifier, + navController = navController, + startDestination = startDestination, + enterTransition = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(220), + ) + }, + exitTransition = { + fadeOut(animationSpec = tween(180)) + + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(180), + ) + }, + popEnterTransition = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(220), + ) + }, + popExitTransition = { + fadeOut(animationSpec = tween(180)) + + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(180), + ) + }, + ) { + composable(Routes.MAIN) { + MainScreen( + onNavigateSettings = { navController.navigate(Routes.SETTINGS) }, + onPickFiles = onPickFiles, + ) + } + composable(Routes.SETTINGS) { + SettingsScreen( + onBack = { navController.popBackStack() }, + onSaved = { navController.popBackStack() }, + ) + } + // Share 路由通过 Intent 直接触发 Activity,不纳入 NavHost + // 因为 ShareActivity 接收 ACTION_SEND / SEND_MULTIPLE + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt new file mode 100644 index 0000000..399b407 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/MainScreen.kt @@ -0,0 +1,189 @@ +package moe.reimu.catshare.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import moe.reimu.catshare.R +import moe.reimu.catshare.ui.components.CatCard +import moe.reimu.catshare.ui.components.CatCardVariant +import moe.reimu.catshare.ui.components.CatHeroCard +import moe.reimu.catshare.ui.components.CatIcon +import moe.reimu.catshare.ui.components.CatIconSize +import moe.reimu.catshare.ui.components.CatTopAppBar +import moe.reimu.catshare.ui.viewmodel.MainViewModel +import rikka.shizuku.Shizuku + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun MainScreen( + onNavigateSettings: () -> Unit, + onPickFiles: () -> Unit, + viewModel: MainViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val macPermissionState = rememberPermissionState("android.permission.LOCAL_MAC_ADDRESS") + val scrollState = rememberLazyListState() + + Scaffold( + topBar = { + CatTopAppBar( + title = stringResource(R.string.app_name), + actions = { + IconButton(onClick = onNavigateSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.title_activity_settings), + ) + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + state = scrollState, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + CatHeroCard( + title = stringResource(R.string.send), + subtitle = stringResource(R.string.send_desc), + onClick = onPickFiles, + icon = { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + }, + ) + } + + item { + CatCard(variant = CatCardVariant.Filled) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CatIcon( + imageVector = Icons.AutoMirrored.Filled.BluetoothSearching, + contentDescription = null, + size = CatIconSize.Medium, + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.discoverable), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.discoverable_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Switch( + checked = uiState.isReceiverRunning, + onCheckedChange = { target -> + viewModel.toggleReceiver(target) + }, + ) + } + } + } + + if (!macPermissionState.status.isGranted) { + item { + val (stateIcon, stateTitle, stateDesc) = when { + uiState.shizukuAvailable && uiState.shizukuGranted -> Triple( + Icons.Filled.Done, + stringResource(R.string.shizuku_available), + stringResource(R.string.shizuku_desc), + ) + uiState.shizukuAvailable && !uiState.shizukuGranted -> Triple( + Icons.Filled.Close, + stringResource(R.string.shizuku_not_granted), + stringResource(R.string.shizuku_desc), + ) + else -> Triple( + Icons.Filled.Close, + stringResource(R.string.shizuku_unavailable), + stringResource(R.string.shizuku_desc), + ) + } + CatCard( + variant = CatCardVariant.Outlined, + onClick = if (!uiState.shizukuGranted) { + { + try { + Shizuku.requestPermission(0) + } catch (_: Throwable) { + } + } + } else null, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CatIcon( + imageVector = stateIcon, + contentDescription = null, + size = CatIconSize.Medium, + containerColor = if (uiState.shizukuGranted) { + MaterialTheme.colorScheme.tertiaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + ) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = stateTitle, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stateDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..d12477b --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/SettingsScreen.kt @@ -0,0 +1,186 @@ +package moe.reimu.catshare.ui.screens + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import moe.reimu.catshare.R +import moe.reimu.catshare.ui.components.CatCard +import moe.reimu.catshare.ui.components.CatCardVariant +import moe.reimu.catshare.ui.components.CatTopAppBar +import moe.reimu.catshare.ui.viewmodel.SettingsViewModel +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + onSaved: () -> Unit, + viewModel: SettingsViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.load(context) + } + + Scaffold( + topBar = { + CatTopAppBar( + title = stringResource(R.string.title_activity_settings), + actions = { + IconButton(onClick = { + viewModel.save(context) + onSaved() + }) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Save", + ) + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + CatCard(variant = CatCardVariant.Filled) { + OutlinedTextField( + value = uiState.deviceName, + onValueChange = viewModel::updateDeviceName, + label = { Text(stringResource(R.string.device_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } + + item { + CatCard(variant = CatCardVariant.Filled) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.verbose_name), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = uiState.verbose, + onCheckedChange = { viewModel.toggleVerbose() }, + ) + } + } + } + + item { + CatCard(variant = CatCardVariant.Filled) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.auto_accept_name), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = uiState.autoAccept, + onCheckedChange = { viewModel.toggleAutoAccept() }, + ) + } + } + } + + item { + CatCard( + variant = CatCardVariant.Outlined, + onClick = { + Thread { + try { + val logDir = File(context.cacheDir, "logs") + logDir.mkdirs() + val logFile = File(logDir, "logcat.txt") + logFile.outputStream().use { + val proc = Runtime.getRuntime().exec("logcat -d") + try { + proc.inputStream.copyTo(it) + } finally { + proc.destroy() + } + } + val uri = androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileProvider", + logFile, + ) + val intent = Intent(Intent.ACTION_SEND) + .putExtra(Intent.EXTRA_STREAM, uri) + .setType("text/plain") + .setFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_ACTIVITY_NEW_TASK + ) + context.startActivity(intent) + } catch (_: Throwable) { + // 静默失败 + } + }.start() + }, + ) { + Column { + Text( + text = stringResource(R.string.capture_logs), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.capture_logs_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt new file mode 100644 index 0000000..42050fc --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/screens/ShareScreen.kt @@ -0,0 +1,170 @@ +package moe.reimu.catshare.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import moe.reimu.catshare.R +import moe.reimu.catshare.models.DiscoveredDevice +import moe.reimu.catshare.models.FileInfo +import moe.reimu.catshare.ui.components.CatCard +import moe.reimu.catshare.ui.components.CatCardVariant +import moe.reimu.catshare.ui.components.CatChildAppBar +import moe.reimu.catshare.ui.components.CatIcon +import moe.reimu.catshare.ui.components.CatIconSize +import moe.reimu.catshare.ui.components.CatPullRefresh +import moe.reimu.catshare.ui.components.EmptyState +import moe.reimu.catshare.ui.components.ScanningState +import moe.reimu.catshare.ui.viewmodel.ShareViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareScreen( + files: List, + onBack: () -> Unit, + onDeviceSelected: () -> Unit, + viewModel: ShareViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.startScan() + } + + Scaffold( + topBar = { + CatChildAppBar( + title = stringResource(R.string.choose_recipient), + onBack = onBack, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // 顶部信息:正在发送的文件数 + if (files.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.sending), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "${files.size} ${ + if (files.size == 1) { + files.firstOrNull()?.name ?: "" + } else { + "files" + } + }", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val onPick: (DiscoveredDevice) -> Unit = { device -> + viewModel.startTask(device, files) + onDeviceSelected() + } + + CatPullRefresh( + isRefreshing = false, + onRefresh = { viewModel.startScan() }, + contentPadding = PaddingValues(16.dp), + ) { + when { + uiState.devices.isEmpty() -> { + item { + if (uiState.isScanning) { + ScanningState( + icon = Icons.Filled.AccountCircle, + title = stringResource(R.string.scanning_desc), + ) + } else { + EmptyState( + icon = Icons.Filled.AccountCircle, + title = stringResource(R.string.choose_recipient), + description = stringResource(R.string.scanning_desc), + actionLabel = "Refresh", + onAction = { viewModel.startScan() }, + ) + } + } + } + else -> { + items( + items = uiState.devices, + key = { it.id }, + ) { device -> + DeviceRow( + device = device, + onClick = { onPick(device) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun DeviceRow( + device: DiscoveredDevice, + onClick: () -> Unit, +) { + CatCard( + variant = CatCardVariant.Clickable, + onClick = onClick, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CatIcon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = null, + size = CatIconSize.Medium, + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = device.brand ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt index 60bef03..5a55624 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt @@ -2,10 +2,35 @@ package moe.reimu.catshare.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +val Orange40 = Color(0xFFE8692A) // primary 暖橘 +val OrangeLight40 = Color(0xFFFF8F5F) // 浅橘色(用于渐变/点缀) +val OrangeVariant40 = Color(0xFFCC5A20) // primaryContainer 深橘 -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Amber40 = Color(0xFFFFB74D) // secondary 琥珀金 +val AmberLight40 = Color(0xFFFFD580) // 浅琥珀 +val AmberVariant40 = Color(0xFFF57C00) // secondaryContainer + +val Mint40 = Color(0xFF2E8B57) // tertiary 薄荷绿 +val MintLight40 = Color(0xFF66BB6A) // 浅绿 +val MintVariant40 = Color(0xFF1B5E3D) // tertiaryContainer + +val Cream40 = Color(0xFFFFFBF5) // surface 奶油白 +val WarmGray40 = Color(0xFFF5EDE2) // surfaceVariant 暖灰 +val TextOnSurface40 = Color(0xFF2A2218) // onSurface 深棕 +val TextOnVariant40 = Color(0xFF5A4A3A) // onSurfaceVariant +val Outline40 = Color(0xFFA08968) // outline 描边色 +val Error40 = Color(0xFFE74C3C) // error 珊瑚红 + +val Orange80 = Color(0xFFFFAB7B) // primary +val OrangeVariant80 = Color(0xFFCC5A20) // primaryContainer +val Amber80 = Color(0xFFFFCC80) // secondary +val AmberVariant80 = Color(0xFF8B4513) // secondaryContainer +val Mint80 = Color(0xFF81C784) // tertiary +val MintVariant80 = Color(0xFF2E7D32) // tertiaryContainer + +val Cream80 = Color(0xFF1A1612) // surface 暖深棕 +val WarmGray80 = Color(0xFF2A2420) // surfaceVariant +val TextOnSurface80 = Color(0xFFF5EDE2) // onSurface +val TextOnVariant80 = Color(0xFFBFA98E) // onSurfaceVariant +val Outline80 = Color(0xFF7A6B5A) // outline +val Error80 = Color(0xFFFF6B6B) // error diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt new file mode 100644 index 0000000..c6db948 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Shape.kt @@ -0,0 +1,16 @@ +package moe.reimu.catshare.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val CatShareShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(36.dp), +) + +val PillShape = RoundedCornerShape(percent = 50) +val TopLargeShape = RoundedCornerShape(topStart = 36.dp, topEnd = 36.dp) diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt index e66d886..b89baa2 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt @@ -8,50 +8,156 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightColorScheme = lightColorScheme( + primary = Orange40, + onPrimary = Color(0xFFFFFFFF), + primaryContainer = OrangeVariant40, + onPrimaryContainer = Color(0xFFFFFFFF), + + secondary = Amber40, + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = AmberLight40, + onSecondaryContainer = Color(0xFF3A2500), + + tertiary = Mint40, + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = MintLight40, + onTertiaryContainer = Color(0xFF0B2E1B), + + error = Error40, + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + + background = Cream40, + onBackground = TextOnSurface40, + + surface = Cream40, + onSurface = TextOnSurface40, + + surfaceVariant = WarmGray40, + onSurfaceVariant = TextOnVariant40, + + surfaceTint = Orange40, + + outline = Outline40, + outlineVariant = Color(0xFFD7C8B5), + + scrim = Color(0x99000000), + + inverseSurface = Color(0xFF3A2E1F), + inverseOnSurface = Color(0xFFF5EDE2), + inversePrimary = OrangeLight40, +) private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = Orange80, + onPrimary = Color(0xFF3A1B00), + primaryContainer = OrangeVariant80, + onPrimaryContainer = Color(0xFFFFDBC7), + + secondary = Amber80, + onSecondary = Color(0xFF3A2500), + secondaryContainer = AmberVariant80, + onSecondaryContainer = Color(0xFFFFDDB3), + + tertiary = Mint80, + onTertiary = Color(0xFF0B2E1B), + tertiaryContainer = MintVariant80, + onTertiaryContainer = Color(0xFFC7EFD6), + + error = Error80, + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + + background = Cream80, + onBackground = TextOnSurface80, + + surface = Cream80, + onSurface = TextOnSurface80, + + surfaceVariant = WarmGray80, + onSurfaceVariant = TextOnVariant80, + + surfaceTint = Orange80, + + outline = Outline80, + outlineVariant = Color(0xFF544B40), + + scrim = Color(0xCC000000), + + inverseSurface = Color(0xFFF5EDE2), + inverseOnSurface = Color(0xFF3A2E1F), + inversePrimary = Orange40, ) -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ +data class CatShareExtraColors( + val heroGradientStart: androidx.compose.ui.graphics.Color, + val heroGradientEnd: androidx.compose.ui.graphics.Color, + val heroTextPrimary: androidx.compose.ui.graphics.Color, + val heroTextSecondary: androidx.compose.ui.graphics.Color, +) + +private val LightExtraColors = CatShareExtraColors( + heroGradientStart = OrangeLight40, + heroGradientEnd = AmberLight40, + heroTextPrimary = Color(0xFFFFFFFF), + heroTextSecondary = Color(0xFFFFFFFF), +) + +private val DarkExtraColors = CatShareExtraColors( + heroGradientStart = OrangeVariant80, + heroGradientEnd = MintVariant80, + heroTextPrimary = Color(0xFFFFFFFF), + heroTextSecondary = Color(0xFFE0D5C3), ) +val LocalCatShareExtraColors = staticCompositionLocalOf { LightExtraColors } + @Composable fun CatShareTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { + val context = LocalContext.current + val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme else -> LightColorScheme } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file + val extraColors = if (darkTheme) DarkExtraColors else LightExtraColors + + // 适配沉浸式状态栏 + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? android.app.Activity)?.window ?: return@SideEffect + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + CompositionLocalProvider( + LocalCatShareExtraColors provides extraColors, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = CatShareTypography, + shapes = CatShareShapes, + content = content, + ) + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt b/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt index 22c20a6..769e2a5 100644 --- a/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt +++ b/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt @@ -6,29 +6,106 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with -val Typography = Typography( +val CatShareTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Light, + fontSize = 45.sp, + lineHeight = 52.sp, + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 36.sp, + lineHeight = 44.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, ), labelSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file + letterSpacing = 0.5.sp, + ), +) + +val Typography = CatShareTypography diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..861152f --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/MainViewModel.kt @@ -0,0 +1,116 @@ +package moe.reimu.catshare.ui.viewmodel + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import moe.reimu.catshare.services.GattServerService +import moe.reimu.catshare.utils.ServiceState +import moe.reimu.catshare.utils.registerInternalBroadcastReceiver +import rikka.shizuku.Shizuku + +data class MainUiState( + val isReceiverRunning: Boolean = false, + val shizukuGranted: Boolean = false, + val shizukuAvailable: Boolean = false, +) + +class MainViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ServiceState.ACTION_UPDATE_RECEIVER_STATE -> { + _uiState.update { + it.copy(isReceiverRunning = intent.getBooleanExtra("isRunning", false)) + } + } + } + } + } + + private val shizukuPermissionListener = Shizuku.OnRequestPermissionResultListener { _, grantResult -> + val available = shizukuPing() + updateShizukuState( + available = available, + granted = grantResult == PackageManager.PERMISSION_GRANTED, + ) + } + + private val shizukuBinderListener = Shizuku.OnBinderReceivedListener { + val available = shizukuPing() + updateShizukuState( + available = available, + granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, + ) + } + + private val shizukuDeadListener = Shizuku.OnBinderDeadListener { + updateShizukuState(available = false, granted = false) + } + + init { + val app = getApplication() + app.registerInternalBroadcastReceiver( + receiver, + IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE), + ) + app.sendBroadcast(ServiceState.getQueryIntent()) + + val available = shizukuPing() + updateShizukuState( + available = available, + granted = available && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED, + ) + + Shizuku.addRequestPermissionResultListener(shizukuPermissionListener) + Shizuku.addBinderReceivedListenerSticky(shizukuBinderListener) + Shizuku.addBinderDeadListener(shizukuDeadListener) + } + + override fun onCleared() { + super.onCleared() + try { + getApplication().unregisterReceiver(receiver) + } catch (_: Throwable) { + } + Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener) + Shizuku.removeBinderReceivedListener(shizukuBinderListener) + Shizuku.removeBinderDeadListener(shizukuDeadListener) + } + + fun updateShizukuState(available: Boolean, granted: Boolean) { + _uiState.update { + it.copy( + shizukuAvailable = available, + shizukuGranted = granted, + ) + } + } + + fun toggleReceiver(targetOn: Boolean) { + val app = getApplication() + if (targetOn) { + GattServerService.start(app) + } else { + GattServerService.stop(app) + } + } + + private fun shizukuPing(): Boolean { + return try { + Shizuku.pingBinder() + } catch (_: Throwable) { + false + } + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..19fe582 --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/SettingsViewModel.kt @@ -0,0 +1,61 @@ +package moe.reimu.catshare.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import moe.reimu.catshare.AppSettings + +data class SettingsUiState( + val deviceName: String = "", + val verbose: Boolean = false, + val autoAccept: Boolean = false, +) + +class SettingsViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = SettingsUiState(), + ) + + fun load(context: Context) { + val settings = AppSettings(context) + _uiState.update { + SettingsUiState( + deviceName = settings.deviceName, + verbose = settings.verbose, + autoAccept = settings.autoAccept, + ) + } + } + + fun updateDeviceName(name: String) { + _uiState.update { it.copy(deviceName = name) } + } + + fun toggleVerbose() { + _uiState.update { it.copy(verbose = !it.verbose) } + } + + fun toggleAutoAccept() { + _uiState.update { it.copy(autoAccept = !it.autoAccept) } + } + + fun save(context: Context) { + val state = _uiState.value + val settings = AppSettings(context) + if (state.deviceName.isNotBlank()) { + settings.deviceName = state.deviceName + } + settings.verbose = state.verbose + settings.autoAccept = state.autoAccept + } +} diff --git a/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt new file mode 100644 index 0000000..e7f406b --- /dev/null +++ b/app/src/main/java/moe/reimu/catshare/ui/viewmodel/ShareViewModel.kt @@ -0,0 +1,164 @@ +package moe.reimu.catshare.ui.viewmodel + +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.os.ParcelUuid +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import moe.reimu.catshare.MyApplication +import moe.reimu.catshare.models.DiscoveredDevice +import moe.reimu.catshare.models.FileInfo +import moe.reimu.catshare.services.P2pSenderService +import moe.reimu.catshare.utils.BleUtils +import moe.reimu.catshare.utils.DeviceUtils +import java.nio.ByteBuffer + +data class ShareUiState( + val devices: List = emptyList(), + val isScanning: Boolean = false, + val errorMessage: String? = null, +) + +class ShareViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(ShareUiState()) + val uiState: StateFlow = _uiState.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = ShareUiState(), + ) + + private var scanner: BluetoothLeScanner? = null + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult?) { + val record = result?.scanRecord ?: return + var supports5Ghz = false + var deviceName: String? + var brandId: Byte? = null + + for ((uuid, data) in record.serviceData.entries) { + when (data.size) { + 6 -> { + // 品牌 + 5GHz 标志位 + val buf = ByteBuffer.allocate(16) + buf.putLong(uuid.uuid.mostSignificantBits) + buf.putLong(uuid.uuid.leastSignificantBits) + val arr = buf.array() + supports5Ghz = arr[2].toInt() == 1 + brandId = arr[3] + } + + 27 -> { + // 设备名称 + senderId + val nameBuf = mutableListOf() + for (i in 10..25) { + if (data[i].toInt() != 0) { + nameBuf.add(data[i]) + } else { + break + } + } + val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt()) + val senderId = String.format("%04x", senderIdRaw) + var name = nameBuf.toByteArray().decodeToString() + if (name.lastOrNull() == '\t') { + name = name.removeSuffix("\t") + "..." + } + deviceName = name + val brand = brandId?.let { DeviceUtils.deviceNameById(it) } + val newDevice = DiscoveredDevice( + device = result.device, + id = senderId, + name = deviceName, + brand = brand, + supports5Ghz = supports5Ghz, + ) + addOrReplace(newDevice) + } + } + } + } + + override fun onScanFailed(errorCode: Int) { + _uiState.update { + it.copy( + errorMessage = "Scan failed (code=$errorCode)", + isScanning = false, + ) + } + } + } + + @SuppressLint("MissingPermission") + fun startScan() { + if (_uiState.value.isScanning) return + val context = getApplication() + val btManager = context.getSystemService(BluetoothManager::class.java) + val adapter = btManager.adapter ?: return + + try { + val filters = listOf( + ScanFilter.Builder().setServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID)).build() + ) + val settings = + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + adapter.bluetoothLeScanner.startScan(filters, settings, scanCallback) + scanner = adapter.bluetoothLeScanner + _uiState.update { it.copy(isScanning = true, errorMessage = null) } + } catch (e: SecurityException) { + _uiState.update { + it.copy(errorMessage = "Bluetooth permission denied: ${e.message}") + } + } + } + + @SuppressLint("MissingPermission") + fun stopScan() { + try { + scanner?.stopScan(scanCallback) + } catch (_: Exception) { + } + scanner = null + _uiState.update { it.copy(isScanning = false) } + } + + private fun addOrReplace(device: DiscoveredDevice) { + _uiState.update { state -> + val replaced = state.devices.map { + if (it.id == device.id) device else it + } + val newList = + if (replaced.size == state.devices.size && replaced.none { it.id == device.id }) { + state.devices + device + } else { + replaced + } + state.copy(devices = newList) + } + } + + fun startTask(device: DiscoveredDevice, files: List) { + val task = moe.reimu.catshare.models.TaskInfo( + id = kotlin.random.Random.nextInt(), + device = device, + files = files, + ) + P2pSenderService.startTaskChecked(getApplication(), task) + } + + override fun onCleared() { + super.onCleared() + stopScan() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5841d35..8d7dad9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,22 @@ [versions] -agp = "9.0.1" -kotlin = "2.3.10" -coreKtx = "1.17.0" +accompanistPermissions = "0.37.3" +agp = "9.2.1" +kotlin = "2.4.0" +coreKtx = "1.18.0" +kotlinxCoroutinesAndroid = "1.10.2" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.4" -composeBom = "2026.02.00" +activityCompose = "1.13.0" +composeBom = "2026.05.01" +navigationCompose = "2.9.8" +lifecycleViewmodelCompose = "2.10.0" ktor = "3.0.3" appcompat = "1.7.1" -material = "1.13.0" -activity = "1.12.4" +material = "1.14.0" +activity = "1.13.0" shizuku = "13.1.5" [libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -21,7 +26,10 @@ androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } @@ -46,4 +54,3 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } - diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0afc110..c1a3a6f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Dec 29 17:01:12 CST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists