From 71b936ba3a5dc913400c520541617b13cbf78165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 30 Dec 2025 18:01:54 +0800 Subject: [PATCH] Remove legacy View-based UI Keep only Compose UI, removing old View-based Activities, Fragments, and layouts. QRScanActivity and PerAppProxyActivity are retained as they are still used by Compose UI. --- app/src/main/AndroidManifest.xml | 46 -- .../io/nekohasekai/sfa/LauncherActivity.kt | 14 +- .../java/io/nekohasekai/sfa/bg/BoxService.kt | 4 +- .../compose/screen/settings/SettingsScreen.kt | 58 -- .../nekohasekai/sfa/constant/SettingsKey.kt | 1 - .../io/nekohasekai/sfa/database/Settings.kt | 1 - .../java/io/nekohasekai/sfa/ktx/Shares.kt | 31 -- .../io/nekohasekai/sfa/ui/MainActivity.kt | 445 --------------- .../io/nekohasekai/sfa/ui/ShortcutActivity.kt | 93 ---- .../sfa/ui/dashboard/GroupsFragment.kt | 343 ------------ .../sfa/ui/dashboard/OverviewFragment.kt | 389 ------------- .../nekohasekai/sfa/ui/debug/DebugActivity.kt | 18 - .../sfa/ui/debug/VPNScanActivity.kt | 274 --------- .../sfa/ui/main/ConfigurationFragment.kt | 294 ---------- .../sfa/ui/main/DashboardFragment.kt | 187 ------- .../io/nekohasekai/sfa/ui/main/LogFragment.kt | 198 ------- .../sfa/ui/main/SettingsFragment.kt | 173 ------ .../sfa/ui/profile/EditProfileActivity.kt | 203 ------- .../ui/profile/EditProfileContentActivity.kt | 138 ----- .../sfa/ui/profile/NewProfileActivity.kt | 221 -------- .../ProfileOverrideActivity.kt | 27 - .../sfa/ui/shared/AbstractActivity.kt | 21 +- .../nekohasekai/sfa/ui/shared/QRCodeDialog.kt | 25 - .../main/res/layout/activity_add_profile.xml | 188 ------- .../res/layout/activity_config_override.xml | 80 --- app/src/main/res/layout/activity_debug.xml | 75 --- .../main/res/layout/activity_edit_profile.xml | 160 ------ .../layout/activity_edit_profile_content.xml | 40 -- app/src/main/res/layout/activity_main.xml | 73 --- app/src/main/res/layout/activity_vpn_scan.xml | 40 -- app/src/main/res/layout/dialog_progress.xml | 19 - .../res/layout/fragment_configuration.xml | 43 -- .../main/res/layout/fragment_dashboard.xml | 23 - .../res/layout/fragment_dashboard_groups.xml | 23 - .../layout/fragment_dashboard_overview.xml | 527 ------------------ app/src/main/res/layout/fragment_log.xml | 43 -- .../res/layout/fragment_qrcode_dialog.xml | 30 - app/src/main/res/layout/fragment_settings.xml | 459 --------------- app/src/main/res/layout/sheet_add_profile.xml | 95 ---- .../main/res/layout/view_app_list_item0.xml | 64 --- .../res/layout/view_clash_mode_button.xml | 20 - .../res/layout/view_configutation_item.xml | 62 --- .../main/res/layout/view_dashboard_group.xml | 102 ---- .../res/layout/view_dashboard_group_item.xml | 81 --- .../main/res/layout/view_log_text_item.xml | 19 - .../res/layout/view_prefenence_screen.xml | 10 - app/src/main/res/layout/view_profile_item.xml | 23 - app/src/main/res/layout/view_vpn_app_item.xml | 186 ------- app/src/main/res/menu/bottom_nav_menu.xml | 23 - .../main/res/menu/edit_configutation_menu.xml | 20 - app/src/main/res/menu/profile_menu.xml | 26 - .../main/res/navigation/mobile_navigation.xml | 33 -- 52 files changed, 12 insertions(+), 5779 deletions(-) delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt delete mode 100644 app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt delete mode 100644 app/src/main/res/layout/activity_add_profile.xml delete mode 100644 app/src/main/res/layout/activity_config_override.xml delete mode 100644 app/src/main/res/layout/activity_debug.xml delete mode 100644 app/src/main/res/layout/activity_edit_profile.xml delete mode 100644 app/src/main/res/layout/activity_edit_profile_content.xml delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/main/res/layout/activity_vpn_scan.xml delete mode 100644 app/src/main/res/layout/dialog_progress.xml delete mode 100644 app/src/main/res/layout/fragment_configuration.xml delete mode 100644 app/src/main/res/layout/fragment_dashboard.xml delete mode 100644 app/src/main/res/layout/fragment_dashboard_groups.xml delete mode 100644 app/src/main/res/layout/fragment_dashboard_overview.xml delete mode 100644 app/src/main/res/layout/fragment_log.xml delete mode 100644 app/src/main/res/layout/fragment_qrcode_dialog.xml delete mode 100644 app/src/main/res/layout/fragment_settings.xml delete mode 100644 app/src/main/res/layout/sheet_add_profile.xml delete mode 100644 app/src/main/res/layout/view_app_list_item0.xml delete mode 100644 app/src/main/res/layout/view_clash_mode_button.xml delete mode 100644 app/src/main/res/layout/view_configutation_item.xml delete mode 100644 app/src/main/res/layout/view_dashboard_group.xml delete mode 100644 app/src/main/res/layout/view_dashboard_group_item.xml delete mode 100644 app/src/main/res/layout/view_log_text_item.xml delete mode 100644 app/src/main/res/layout/view_prefenence_screen.xml delete mode 100644 app/src/main/res/layout/view_profile_item.xml delete mode 100644 app/src/main/res/layout/view_vpn_app_item.xml delete mode 100644 app/src/main/res/menu/bottom_nav_menu.xml delete mode 100644 app/src/main/res/menu/edit_configutation_menu.xml delete mode 100644 app/src/main/res/menu/profile_menu.xml delete mode 100644 app/src/main/res/navigation/mobile_navigation.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f3ec7d..ef729a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,21 +95,6 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt b/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt index a844fe7..1b86ea3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt @@ -4,25 +4,13 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import io.nekohasekai.sfa.compose.ComposeActivity -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ui.MainActivity -import kotlinx.coroutines.runBlocking class LauncherActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val targetActivity = - if (BuildConfig.DEBUG) { - val useComposeUI = runBlocking { Settings.useComposeUI } - if (useComposeUI) ComposeActivity::class.java else MainActivity::class.java - } else { - ComposeActivity::class.java - } - val launchIntent = - Intent(this, targetActivity).apply { - // Transfer any intent data from launcher + Intent(this, ComposeActivity::class.java).apply { intent?.let { action = it.action data = it.data diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index eb1b7f0..ede8b1c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -34,7 +34,7 @@ import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.ProfileManager import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.hasPermission -import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.compose.ComposeActivity import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -397,7 +397,7 @@ class BoxService( 0, Intent( service, - MainActivity::class.java, + ComposeActivity::class.java, ).apply { setAction(Action.OPEN_URL).setData(Uri.parse(notification.openURL)) setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index ca9d733..4c20f6e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -319,64 +319,6 @@ fun SettingsScreen(navController: NavController) { } } - if (BuildConfig.DEBUG) { - // Debug - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.title_debug), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.switch_to_legacy_ui), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.SwapHoriz, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = - Modifier - .clip(RoundedCornerShape(12.dp)) - .clickable { - scope.launch(Dispatchers.IO) { - Settings.useComposeUI = false - val intent = - android.content.Intent( - context, - Class.forName("io.nekohasekai.sfa.ui.MainActivity"), - ) - intent.flags = - android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) - } - }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 291d155..8193377 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -11,7 +11,6 @@ object SettingsKey { const val AUTO_UPDATE_ENABLED = "auto_update_enabled" const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" const val DYNAMIC_NOTIFICATION = "dynamic_notification" - const val USE_COMPOSE_UI = "use_compose_ui" const val DISABLE_DEPRECATED_WARNINGS = "disable_deprecated_warnings" const val AUTO_REDIRECT = "auto_redirect" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index ee1850c..572b0fb 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -65,7 +65,6 @@ object Settings { var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT) var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } - var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true } var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } const val PER_APP_PROXY_DISABLED = 0 diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt index f5120a8..8fd3902 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt @@ -2,18 +2,11 @@ package io.nekohasekai.sfa.ktx import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Color import androidx.core.content.FileProvider -import androidx.fragment.app.FragmentActivity import androidx.appcompat.R as AppCompatR -import com.google.zxing.BarcodeFormat -import com.google.zxing.qrcode.QRCodeWriter -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.ui.shared.QRCodeDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -51,27 +44,3 @@ suspend fun Context.shareProfile(profile: Profile) { ) } } - -fun FragmentActivity.shareProfileURL(profile: Profile) { - val link = - Libbox.generateRemoteProfileImportLink( - profile.name, - profile.typed.remoteURL, - ) - val imageSize = dp2px(256) - val color = getAttrColor(androidx.appcompat.R.attr.colorPrimary) - val image = QRCodeWriter().encode(link, BarcodeFormat.QR_CODE, imageSize, imageSize, null) - val imageWidth = image.width - val imageHeight = image.height - val imageArray = IntArray(imageWidth * imageHeight) - for (y in 0 until imageHeight) { - val offset = y * imageWidth - for (x in 0 until imageWidth) { - imageArray[offset + x] = if (image.get(x, y)) color else Color.TRANSPARENT - } - } - val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888) - bitmap.setPixels(imageArray, 0, imageSize, 0, 0, imageWidth, imageHeight) - val dialog = QRCodeDialog(bitmap) - dialog.show(supportFragmentManager, "share-profile-url") -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt deleted file mode 100644 index 70a902c..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt +++ /dev/null @@ -1,445 +0,0 @@ -package io.nekohasekai.sfa.ui - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.net.VpnService -import android.os.Build -import android.os.Bundle -import android.text.Html -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupActionBarWithNavController -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.ProfileContent -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.ServiceConnection -import io.nekohasekai.sfa.bg.ServiceNotification -import io.nekohasekai.sfa.constant.Action -import io.nekohasekai.sfa.constant.Alert -import io.nekohasekai.sfa.constant.ServiceMode -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.ActivityMainBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.hasPermission -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.ui.profile.NewProfileActivity -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.utils.MIUIUtils -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.Date - -class MainActivity : - AbstractActivity(), - ServiceConnection.Callback { - companion object { - private const val TAG = "MainActivity" - } - - private lateinit var navHostFragment: NavHostFragment - private lateinit var navController: NavController - private lateinit var appBarConfiguration: AppBarConfiguration - - private val connection = ServiceConnection(this, this) - - val serviceStatus = MutableLiveData(Status.Stopped) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_my) as NavHostFragment - navController = navHostFragment.navController - navController.setGraph(R.navigation.mobile_navigation) - navController.addOnDestinationChangedListener(::onDestinationChanged) - appBarConfiguration = - AppBarConfiguration( - setOf( - R.id.navigation_dashboard, - R.id.navigation_log, - R.id.navigation_configuration, - R.id.navigation_settings, - ), - ) - setupActionBarWithNavController(navController, appBarConfiguration) - binding.navView.setupWithNavController(navController) - reconnect() - startIntegration() - - onNewIntent(intent) - } - - override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp(appBarConfiguration) - } - - @Suppress("UNUSED_PARAMETER") - private fun onDestinationChanged( - navController: NavController, - navDestination: NavDestination, - bundle: Bundle?, - ) { - val destinationId = navDestination.id - binding.dashboardTabContainer.isVisible = destinationId == R.id.navigation_dashboard - } - - public override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - val uri = intent.data ?: return - when (intent.action) { - Action.OPEN_URL -> { - launchCustomTab(uri.toString()) - return - } - } - if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { - val profile = - try { - Libbox.parseRemoteProfileImportLink(uri.toString()) - } catch (e: Exception) { - errorDialogBuilder(e).show() - return - } - MaterialAlertDialogBuilder(this) - .setTitle(R.string.import_remote_profile) - .setMessage( - getString( - R.string.import_remote_profile_message, - profile.name, - profile.host, - ), - ) - .setPositiveButton(R.string.ok) { _, _ -> - startActivity( - Intent(this, NewProfileActivity::class.java).apply { - putExtra("importName", profile.name) - putExtra("importURL", profile.url) - }, - ) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } else if (intent.action == Intent.ACTION_VIEW) { - try { - val data = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return - val content = Libbox.decodeProfileContent(data) - MaterialAlertDialogBuilder(this) - .setTitle(R.string.import_profile) - .setMessage( - getString( - R.string.import_profile_message, - content.name, - ), - ) - .setPositiveButton(R.string.ok) { _, _ -> - lifecycleScope.launch { - withContext(Dispatchers.IO) { - runCatching { - importProfile(content) - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it).show() - } - } - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } catch (e: Exception) { - errorDialogBuilder(e).show() - } - } - } - - private suspend fun importProfile(content: ProfileContent) { - val typedProfile = TypedProfile() - val profile = Profile(name = content.name, typed = typedProfile) - profile.userOrder = ProfileManager.nextOrder() - when (content.type) { - Libbox.ProfileTypeLocal -> { - typedProfile.type = TypedProfile.Type.Local - } - - Libbox.ProfileTypeiCloud -> { - errorDialogBuilder(R.string.icloud_profile_unsupported).show() - return - } - - Libbox.ProfileTypeRemote -> { - typedProfile.type = TypedProfile.Type.Remote - typedProfile.remoteURL = content.remotePath - typedProfile.autoUpdate = content.autoUpdate - typedProfile.autoUpdateInterval = content.autoUpdateInterval - typedProfile.lastUpdated = Date(content.lastUpdated) - } - } - val configDirectory = File(filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") - configFile.writeText(content.config) - typedProfile.path = configFile.path - ProfileManager.create(profile) - } - - fun reconnect() { - connection.reconnect() - } - - private fun startIntegration() { - lifecycleScope.launch(Dispatchers.IO) { - if (Settings.checkUpdateEnabled) { - Vendor.checkUpdate(this@MainActivity, false) - } - } - } - - @SuppressLint("NewApi") - fun startService() { - if (!ServiceNotification.checkPermission()) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - return - } - startService0() - } - - private fun startService0() { - lifecycleScope.launch(Dispatchers.IO) { - if (Settings.rebuildServiceMode()) { - reconnect() - } - if (Settings.serviceMode == ServiceMode.VPN) { - if (prepare()) { - return@launch - } - } - val intent = Intent(Application.application, Settings.serviceClass()) - withContext(Dispatchers.Main) { - ContextCompat.startForegroundService(Application.application, intent) - } - } - } - - private val notificationPermissionLauncher = - registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { - if (Settings.dynamicNotification && !it) { - onServiceAlert(Alert.RequestNotificationPermission, null) - } else { - startService0() - } - } - - private val locationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - requestBackgroundLocationPermission() - } else { - startService() - } - } - } - - private val backgroundLocationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - startService() - } - } - - private val prepareLauncher = - registerForActivityResult(PrepareService()) { - if (it) { - startService() - } else { - onServiceAlert(Alert.RequestVPNPermission, null) - } - } - - private class PrepareService : ActivityResultContract() { - override fun createIntent( - context: Context, - input: Intent, - ): Intent { - return input - } - - override fun parseResult( - resultCode: Int, - intent: Intent?, - ): Boolean { - return resultCode == RESULT_OK - } - } - - private suspend fun prepare() = - withContext(Dispatchers.Main) { - try { - val intent = VpnService.prepare(this@MainActivity) - if (intent != null) { - prepareLauncher.launch(intent) - true - } else { - false - } - } catch (e: Exception) { - onServiceAlert(Alert.RequestVPNPermission, e.message) - false - } - } - - override fun onServiceStatusChanged(status: Status) { - serviceStatus.postValue(status) - } - - override fun onServiceAlert( - type: Alert, - message: String?, - ) { - when (type) { - Alert.RequestLocationPermission -> { - return requestLocationPermission() - } - - else -> {} - } - - val builder = MaterialAlertDialogBuilder(this) - builder.setPositiveButton(R.string.ok, null) - when (type) { - Alert.RequestVPNPermission -> { - builder.setMessage(getString(R.string.service_error_missing_permission)) - } - - Alert.RequestNotificationPermission -> { - builder.setTitle(R.string.notification_permission_title) - builder.setMessage(R.string.notification_permission_required_description) - } - - Alert.EmptyConfiguration -> { - builder.setMessage(getString(R.string.service_error_empty_configuration)) - } - - Alert.StartCommandServer -> { - builder.setTitle(getString(R.string.service_error_title_start_command_server)) - builder.setMessage(message) - } - - Alert.CreateService -> { - builder.setTitle(getString(R.string.service_error_title_create_service)) - builder.setMessage(message) - } - - Alert.StartService -> { - builder.setTitle(getString(R.string.service_error_title_start_service)) - builder.setMessage(message) - } - - else -> {} - } - builder.show() - } - - private fun requestLocationPermission() { - if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { - requestFineLocationPermission() - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - requestBackgroundLocationPermission() - } - } - - private fun requestFineLocationPermission() { - val message = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Html.fromHtml( - getString(R.string.location_permission_description), - Html.FROM_HTML_MODE_LEGACY, - ) - } else { - @Suppress("DEPRECATION") - Html.fromHtml(getString(R.string.location_permission_description)) - } - MaterialAlertDialogBuilder(this) - .setTitle(R.string.location_permission_title) - .setMessage(message) - .setPositiveButton(R.string.ok) { _, _ -> - requestFineLocationPermission0() - } - .setNegativeButton(R.string.no_thanks, null) - .setCancelable(false) - .show() - } - - private fun requestFineLocationPermission0() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } else { - openPermissionSettings() - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun requestBackgroundLocationPermission() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.location_permission_title) - .setMessage( - Html.fromHtml( - getString(R.string.location_permission_background_description), - Html.FROM_HTML_MODE_LEGACY, - ), - ) - .setPositiveButton(R.string.ok) { _, _ -> - backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) - } - .setNegativeButton(R.string.no_thanks, null) - .setCancelable(false) - .show() - } - - private fun openPermissionSettings() { - if (MIUIUtils.isMIUI) { - try { - MIUIUtils.openPermissionSettings(this) - return - } catch (ignored: Exception) { - } - } - - try { - val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.parse("package:$packageName") - startActivity(intent) - } catch (e: Exception) { - errorDialogBuilder(e).show() - } - } - - override fun onDestroy() { - connection.disconnect() - super.onDestroy() - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt deleted file mode 100644 index 42231b9..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt +++ /dev/null @@ -1,93 +0,0 @@ -package io.nekohasekai.sfa.ui - -import android.app.Activity -import android.app.KeyguardManager -import android.content.Intent -import android.content.pm.ShortcutManager -import android.os.Build -import android.os.Bundle -import androidx.core.content.getSystemService -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.bg.ServiceConnection -import io.nekohasekai.sfa.constant.Status - -class ShortcutActivity : Activity(), ServiceConnection.Callback { - private val connection = ServiceConnection(this, this, false) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (intent.action == Intent.ACTION_CREATE_SHORTCUT) { - setResult( - RESULT_OK, - ShortcutManagerCompat.createShortcutResultIntent( - this, - ShortcutInfoCompat.Builder(this, "toggle") - .setIntent( - Intent( - this, - ShortcutActivity::class.java, - ).setAction(Intent.ACTION_MAIN), - ) - .setIcon( - IconCompat.createWithResource( - this, - R.mipmap.ic_launcher, - ), - ) - .setShortLabel(getString(R.string.quick_toggle)) - .build(), - ), - ) - finish() - } else { - val keyguardManager = getSystemService() - if (keyguardManager?.isKeyguardLocked == true) { - if (Build.VERSION.SDK_INT >= 26) { - keyguardManager.requestDismissKeyguard(this, object : KeyguardManager.KeyguardDismissCallback() { - override fun onDismissSucceeded() { - super.onDismissSucceeded() - connectAndToggle() - } - override fun onDismissCancelled() { - super.onDismissCancelled() - finish() - } - override fun onDismissError() { - super.onDismissError() - finish() - } - }) - } else { - finish() - } - } else { - connectAndToggle() - } - } - } - - private fun connectAndToggle() { - connection.connect() - if (Build.VERSION.SDK_INT >= 25) { - getSystemService()?.reportShortcutUsed("toggle") - } - } - - override fun onServiceStatusChanged(status: Status) { - when (status) { - Status.Started -> BoxService.stop() - Status.Stopped -> BoxService.start() - else -> {} - } - finish() - } - - override fun onDestroy() { - connection.disconnect() - super.onDestroy() - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt deleted file mode 100644 index 3838ae0..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt +++ /dev/null @@ -1,343 +0,0 @@ -package io.nekohasekai.sfa.ui.dashboard - -import android.annotation.SuppressLint -import android.os.Bundle -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.SimpleItemAnimator -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.OutboundGroup -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding -import io.nekohasekai.sfa.databinding.ViewDashboardGroupBinding -import io.nekohasekai.sfa.databinding.ViewDashboardGroupItemBinding -import io.nekohasekai.sfa.ktx.colorForURLTestDelay -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class GroupsFragment : Fragment(), CommandClient.Handler { - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentDashboardGroupsBinding? = null - private var adapter: Adapter? = null - private val commandClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.Groups, this) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentDashboardGroupsBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - adapter = Adapter() - binding.container.adapter = adapter - binding.container.layoutManager = LinearLayoutManager(requireContext()) - activity.serviceStatus.observe(viewLifecycleOwner) { - if (it == Status.Started) { - commandClient.connect() - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - binding = null - commandClient.disconnect() - } - - private var displayed = false - - private fun updateDisplayed(newValue: Boolean) { - val binding = binding ?: return - if (displayed != newValue) { - displayed = newValue - binding.statusText.isVisible = !displayed - binding.container.isVisible = displayed - } - } - - override fun onConnected() { - lifecycleScope.launch(Dispatchers.Main) { - updateDisplayed(true) - } - } - - override fun onDisconnected() { - lifecycleScope.launch(Dispatchers.Main) { - updateDisplayed(false) - } - } - - @SuppressLint("NotifyDataSetChanged") - override fun updateGroups(newGroups: MutableList) { - val adapter = adapter ?: return - activity?.runOnUiThread { - updateDisplayed(newGroups.isNotEmpty()) - adapter.setGroups(newGroups.map(::Group)) - } - } - - private class Adapter : RecyclerView.Adapter() { - private lateinit var groups: MutableList - - @SuppressLint("NotifyDataSetChanged") - fun setGroups(newGroups: List) { - if (!::groups.isInitialized || groups.size != newGroups.size) { - groups = newGroups.toMutableList() - notifyDataSetChanged() - } else { - newGroups.forEachIndexed { index, group -> - if (this.groups[index] != group) { - this.groups[index] = group - notifyItemChanged(index) - } - } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): GroupView { - return GroupView( - ViewDashboardGroupBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - } - - override fun getItemCount(): Int { - if (!::groups.isInitialized) { - return 0 - } - return groups.size - } - - override fun onBindViewHolder( - holder: GroupView, - position: Int, - ) { - holder.bind(groups[position]) - } - } - - private class GroupView(val binding: ViewDashboardGroupBinding) : - RecyclerView.ViewHolder(binding.root) { - private lateinit var group: Group - private lateinit var items: List - private lateinit var adapter: ItemAdapter - private var textWatcher: TextWatcher? = null - - @OptIn(DelicateCoroutinesApi::class) - @SuppressLint("NotifyDataSetChanged") - fun bind(group: Group) { - this.group = group - binding.groupName.text = group.tag - binding.groupType.text = Libbox.proxyDisplayType(group.type) - binding.urlTestButton.setOnClickListener { - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient().urlTest(group.tag) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } - items = group.items - if (!::adapter.isInitialized) { - adapter = ItemAdapter(this, group, items.toMutableList()) - binding.itemList.adapter = adapter - (binding.itemList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = - false - binding.itemList.layoutManager = GridLayoutManager(binding.root.context, 2) - } else { - adapter.group = group - adapter.setItems(items) - } - updateExpand() - } - - @OptIn(DelicateCoroutinesApi::class) - private fun updateExpand(isExpand: Boolean? = null) { - val newExpandStatus = isExpand ?: group.isExpand - if (isExpand != null) { - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, isExpand) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } - binding.itemList.isVisible = newExpandStatus - binding.groupSelected.isVisible = !newExpandStatus - val textView = (binding.groupSelected.editText as MaterialAutoCompleteTextView) - if (textWatcher != null) { - textView.removeTextChangedListener(textWatcher) - } - if (!newExpandStatus) { - binding.groupSelected.text = group.selected - binding.groupSelected.isEnabled = group.selectable - if (group.selectable) { - textView.setSimpleItems(group.items.toList().map { it.tag }.toTypedArray()) - textWatcher = - textView.addTextChangedListener { - val selected = textView.text.toString() - if (selected != group.selected) { - updateSelected(group, selected) - } - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient() - .selectOutbound(group.tag, selected) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } - } - } - if (newExpandStatus) { - binding.urlTestButton.isVisible = true - binding.expandButton.setImageResource(R.drawable.ic_expand_less_24) - } else { - binding.urlTestButton.isVisible = false - binding.expandButton.setImageResource(R.drawable.ic_expand_more_24) - } - binding.expandButton.setOnClickListener { - updateExpand(!binding.itemList.isVisible) - } - } - - fun updateSelected( - group: Group, - itemTag: String, - ) { - val oldSelected = items.indexOfFirst { it.tag == group.selected } - group.selected = itemTag - if (oldSelected != -1) { - adapter.notifyItemChanged(oldSelected) - } - } - } - - private class ItemAdapter( - val groupView: GroupView, - var group: Group, - private var items: MutableList = mutableListOf(), - ) : - RecyclerView.Adapter() { - @SuppressLint("NotifyDataSetChanged") - fun setItems(newItems: List) { - if (items.size != newItems.size) { - items = newItems.toMutableList() - notifyDataSetChanged() - } else { - newItems.forEachIndexed { index, item -> - if (items[index] != item) { - items[index] = item - notifyItemChanged(index) - } - } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ItemGroupView { - return ItemGroupView( - ViewDashboardGroupItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onBindViewHolder( - holder: ItemGroupView, - position: Int, - ) { - holder.bind(groupView, group, items[position]) - } - } - - private class ItemGroupView(val binding: ViewDashboardGroupItemBinding) : - RecyclerView.ViewHolder(binding.root) { - @OptIn(DelicateCoroutinesApi::class) - fun bind( - groupView: GroupView, - group: Group, - item: GroupItem, - ) { - if (group.selectable) { - binding.itemCard.setOnClickListener { - binding.selectedView.isVisible = true - groupView.updateSelected(group, item.tag) - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient().selectOutbound(group.tag, item.tag) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder("select outbound: ${it.localizedMessage}") - .show() - } - } - } - } - } - binding.selectedView.isInvisible = group.selected != item.tag - binding.itemName.text = item.tag - binding.itemType.text = Libbox.proxyDisplayType(item.type) - binding.itemStatus.isVisible = item.urlTestTime > 0 - if (item.urlTestTime > 0) { - binding.itemStatus.text = "${item.urlTestDelay}ms" - binding.itemStatus.setTextColor( - colorForURLTestDelay( - binding.root.context, - item.urlTestDelay, - ), - ) - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt deleted file mode 100644 index 531d942..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt +++ /dev/null @@ -1,389 +0,0 @@ -package io.nekohasekai.sfa.ui.dashboard - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.divider.MaterialDividerItemDecoration -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.StatusMessage -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.FragmentDashboardOverviewBinding -import io.nekohasekai.sfa.databinding.ViewClashModeButtonBinding -import io.nekohasekai.sfa.databinding.ViewProfileItemBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.getAttrColor -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class OverviewFragment : Fragment() { - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentDashboardOverviewBinding? = null - private val statusClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.Status, StatusClient()) - private val clashModeClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.ClashMode, ClashModeClient()) - - private var adapter: Adapter? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentDashboardOverviewBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - binding.profileList.adapter = - Adapter(lifecycleScope, binding).apply { - adapter = this - reload() - } - binding.profileList.layoutManager = LinearLayoutManager(requireContext()) - val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) - divider.isLastItemDecorated = false - binding.profileList.addItemDecoration(divider) - activity.serviceStatus.observe(viewLifecycleOwner) { - binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started - when (it) { - Status.Stopped -> { - binding.clashModeCard.isVisible = false - binding.systemProxyCard.isVisible = false - } - - Status.Started -> { - statusClient.connect() - clashModeClient.connect() - reloadSystemProxyStatus() - } - - else -> {} - } - } - - ProfileManager.registerCallback(this::updateProfiles) - } - - override fun onDestroyView() { - super.onDestroyView() - adapter = null - binding = null - statusClient.disconnect() - clashModeClient.disconnect() - ProfileManager.unregisterCallback(this::updateProfiles) - } - - private fun updateProfiles() { - adapter?.reload() - } - - private fun reloadSystemProxyStatus() { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.IO) { - val status = Libbox.newStandaloneCommandClient().systemProxyStatus - withContext(Dispatchers.Main) { - binding.systemProxyCard.isVisible = status.available - binding.systemProxySwitch.setOnCheckedChangeListener(null) - binding.systemProxySwitch.isChecked = status.enabled - var reloading = false - binding.systemProxySwitch.setOnCheckedChangeListener { buttonView, isChecked -> - synchronized(this@OverviewFragment) { - if (reloading) return@setOnCheckedChangeListener - reloading = true - binding.systemProxySwitch.isEnabled = false - lifecycleScope.launch(Dispatchers.IO) { - Settings.systemProxyEnabled = isChecked - runCatching { - Libbox.newStandaloneCommandClient().setSystemProxyEnabled(isChecked) - }.onFailure { - withContext(Dispatchers.Main) { - buttonView.context.errorDialogBuilder(it).show() - } - } - withContext(Dispatchers.Main) { - delay(1000L) - binding.systemProxySwitch.isEnabled = true - } - } - } - } - } - } - } - - inner class StatusClient : CommandClient.Handler { - override fun onConnected() { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.Main) { - binding.memoryText.text = getString(R.string.loading) - binding.goroutinesText.text = getString(R.string.loading) - } - } - - override fun onDisconnected() { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.Main) { - binding.memoryText.text = getString(R.string.loading) - binding.goroutinesText.text = getString(R.string.loading) - } - } - - override fun updateStatus(status: StatusMessage) { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.Main) { - binding.memoryText.text = Libbox.formatBytes(status.memory) - binding.goroutinesText.text = status.goroutines.toString() - val trafficAvailable = status.trafficAvailable - binding.trafficContainer.isVisible = trafficAvailable - if (trafficAvailable) { - binding.inboundConnectionsText.text = status.connectionsIn.toString() - binding.outboundConnectionsText.text = status.connectionsOut.toString() - binding.uplinkText.text = Libbox.formatBytes(status.uplink) + "/s" - binding.downlinkText.text = Libbox.formatBytes(status.downlink) + "/s" - binding.uplinkTotalText.text = Libbox.formatBytes(status.uplinkTotal) - binding.downlinkTotalText.text = Libbox.formatBytes(status.downlinkTotal) - } - } - } - } - - inner class ClashModeClient : CommandClient.Handler { - override fun initializeClashMode( - modeList: List, - currentMode: String, - ) { - val binding = binding ?: return - if (modeList.size > 1) { - lifecycleScope.launch(Dispatchers.Main) { - binding.clashModeCard.isVisible = true - binding.clashModeList.adapter = ClashModeAdapter(modeList, currentMode) - binding.clashModeList.layoutManager = - GridLayoutManager( - requireContext(), - if (modeList.size < 3) modeList.size else 3, - ) - } - } else { - lifecycleScope.launch(Dispatchers.Main) { - binding.clashModeCard.isVisible = false - } - } - } - - @SuppressLint("NotifyDataSetChanged") - override fun updateClashMode(newMode: String) { - val binding = binding ?: return - val adapter = binding.clashModeList.adapter as? ClashModeAdapter ?: return - adapter.selected = newMode - lifecycleScope.launch(Dispatchers.Main) { - adapter.notifyDataSetChanged() - } - } - } - - private inner class ClashModeAdapter( - val items: List, - var selected: String, - ) : - RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ClashModeItemView { - val view = - ClashModeItemView( - ViewClashModeButtonBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - view.binding.clashModeButton.clipToOutline = true - return view - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onBindViewHolder( - holder: ClashModeItemView, - position: Int, - ) { - holder.bind(items[position], selected) - } - } - - private inner class ClashModeItemView(val binding: ViewClashModeButtonBinding) : - RecyclerView.ViewHolder(binding.root) { - @OptIn(DelicateCoroutinesApi::class) - fun bind( - item: String, - selected: String, - ) { - binding.clashModeButtonText.text = item - if (item != selected) { - binding.clashModeButtonText.setTextColor( - binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimaryContainer), - ) - binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle) - binding.clashModeButton.setOnClickListener { - runCatching { - Libbox.newStandaloneCommandClient().setClashMode(item) - clashModeClient.connect() - }.onFailure { - GlobalScope.launch(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } else { - binding.clashModeButtonText.setTextColor( - binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimary), - ) - binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle_active) - binding.clashModeButton.isClickable = false - } - } - } - - class Adapter( - internal val scope: CoroutineScope, - internal val parent: FragmentDashboardOverviewBinding, - ) : - RecyclerView.Adapter() { - internal var items: MutableList = mutableListOf() - internal var selectedProfileID = -1L - internal var lastSelectedIndex: Int? = null - - internal fun reload() { - scope.launch(Dispatchers.IO) { - items = ProfileManager.list().toMutableList() - if (items.isNotEmpty()) { - selectedProfileID = Settings.selectedProfile - for ((index, profile) in items.withIndex()) { - if (profile.id == selectedProfileID) { - lastSelectedIndex = index - break - } - } - if (lastSelectedIndex == null) { - lastSelectedIndex = 0 - selectedProfileID = items[0].id - Settings.selectedProfile = selectedProfileID - } - } - withContext(Dispatchers.Main) { - parent.statusText.isVisible = items.isEmpty() - parent.container.isVisible = items.isNotEmpty() - notifyDataSetChanged() - } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): Holder { - return Holder( - this, - ViewProfileItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - } - - override fun onBindViewHolder( - holder: Holder, - position: Int, - ) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int { - return items.size - } - } - - class Holder( - private val adapter: Adapter, - private val binding: ViewProfileItemBinding, - ) : - RecyclerView.ViewHolder(binding.root) { - internal fun bind(profile: Profile) { - binding.profileName.text = profile.name - binding.profileSelected.setOnCheckedChangeListener(null) - binding.profileSelected.isChecked = profile.id == adapter.selectedProfileID - binding.profileSelected.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - adapter.parent.profileList.isClickable = false - adapter.selectedProfileID = profile.id - adapter.lastSelectedIndex?.let { index -> - adapter.notifyItemChanged(index) - } - adapter.lastSelectedIndex = adapterPosition - adapter.scope.launch(Dispatchers.IO) { - switchProfile(profile) - withContext(Dispatchers.Main) { - adapter.parent.profileList.isEnabled = true - } - } - } - } - binding.root.setOnClickListener { - binding.profileSelected.toggle() - } - } - - private suspend fun switchProfile(profile: Profile) { - Settings.selectedProfile = profile.id - val mainActivity = (binding.root.context as? MainActivity) ?: return - val started = mainActivity.serviceStatus.value == Status.Started - if (!started) { - return - } - val restart = Settings.rebuildServiceMode() - if (restart) { - mainActivity.reconnect() - BoxService.stop() - delay(1000L) - mainActivity.startService() - return - } - runCatching { - Libbox.newStandaloneCommandClient().serviceReload() - }.onFailure { - withContext(Dispatchers.Main) { - mainActivity.errorDialogBuilder(it).show() - } - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt deleted file mode 100644 index d7138c1..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.nekohasekai.sfa.ui.debug - -import android.content.Intent -import android.os.Bundle -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.databinding.ActivityDebugBinding -import io.nekohasekai.sfa.ui.shared.AbstractActivity - -class DebugActivity : AbstractActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_debug) - binding.scanVPNButton.setOnClickListener { - startActivity(Intent(this, VPNScanActivity::class.java)) - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt deleted file mode 100644 index b03581c..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt +++ /dev/null @@ -1,274 +0,0 @@ -package io.nekohasekai.sfa.ui.debug - -import android.Manifest -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.databinding.ActivityVpnScanBinding -import io.nekohasekai.sfa.databinding.ViewVpnAppItemBinding -import io.nekohasekai.sfa.ktx.dp2px -import io.nekohasekai.sfa.ktx.toStringIterator -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.vendor.PackageQueryManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.zip.ZipFile -import kotlin.math.roundToInt - -class VPNScanActivity : AbstractActivity() { - private var adapter: Adapter? = null - private val appInfoList = mutableListOf() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_scan_vpn) - - ViewCompat.setOnApplyWindowInsetsListener(binding.scanVPNResult) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.updatePadding(bottom = insets.bottom + dp2px(16)) - WindowInsetsCompat.CONSUMED - } - - binding.scanVPNResult.adapter = - Adapter().also { - adapter = it - } - binding.scanVPNResult.layoutManager = LinearLayoutManager(this) - lifecycleScope.launch(Dispatchers.IO) { - scanVPN() - } - } - - class VPNType( - val appType: String?, - val coreType: VPNCoreType?, - ) - - class VPNCoreType( - val coreType: String, - val corePath: String, - val goVersion: String, - ) - - class AppInfo( - val packageInfo: PackageInfo, - val vpnType: VPNType, - ) - - inner class Adapter : RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): Holder { - return Holder(ViewVpnAppItemBinding.inflate(layoutInflater, parent, false)) - } - - override fun getItemCount(): Int { - return appInfoList.size - } - - override fun onBindViewHolder( - holder: Holder, - position: Int, - ) { - holder.bind(appInfoList[position]) - } - } - - class Holder( - private val binding: ViewVpnAppItemBinding, - ) : - RecyclerView.ViewHolder(binding.root) { - fun bind(element: AppInfo) { - binding.appIcon.setImageDrawable(element.packageInfo.applicationInfo!!.loadIcon(binding.root.context.packageManager)) - binding.appName.text = - element.packageInfo.applicationInfo!!.loadLabel(binding.root.context.packageManager) - binding.packageName.text = element.packageInfo.packageName - val appType = element.vpnType.appType - if (appType != null) { - binding.appTypeText.text = element.vpnType.appType - } else { - binding.appTypeText.setText(R.string.vpn_app_type_other) - } - val coreType = element.vpnType.coreType?.coreType - if (coreType != null) { - binding.coreTypeText.text = element.vpnType.coreType.coreType - } else { - binding.coreTypeText.setText(R.string.vpn_core_type_unknown) - } - val corePath = element.vpnType.coreType?.corePath.takeIf { !it.isNullOrBlank() } - if (corePath != null) { - binding.corePathLayout.isVisible = true - binding.corePathText.text = corePath - } else { - binding.corePathLayout.isVisible = false - } - - val goVersion = element.vpnType.coreType?.goVersion.takeIf { !it.isNullOrBlank() } - if (goVersion != null) { - binding.goVersionLayout.isVisible = true - binding.goVersionText.text = goVersion - } else { - binding.goVersionLayout.isVisible = false - } - } - } - - private suspend fun scanVPN() { - val adapter = adapter ?: return - val flag = - PackageManager.GET_SERVICES or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES - } - val installedPackages = PackageQueryManager.getInstalledPackages(flag) - val vpnAppList = - installedPackages.filter { - it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE && it.applicationInfo != null } - ?: false - } - for ((index, packageInfo) in vpnAppList.withIndex()) { - val appType = runCatching { getVPNAppType(packageInfo) }.getOrNull() - val coreType = runCatching { getVPNCoreType(packageInfo) }.getOrNull() - appInfoList.add(AppInfo(packageInfo, VPNType(appType, coreType))) - withContext(Dispatchers.Main) { - adapter.notifyItemInserted(index) - binding.scanVPNResult.scrollToPosition(index) - binding.scanVPNProgress.setProgressCompat( - (((index + 1).toFloat() / vpnAppList.size.toFloat()) * 100).roundToInt(), - true, - ) - } - System.gc() - } - withContext(Dispatchers.Main) { - binding.scanVPNProgress.isVisible = false - } - } - - companion object { - private val v2rayNGClasses = - listOf( - "com.v2ray.ang", - ".dto.V2rayConfig", - ".service.V2RayVpnService", - ) - - private val clashForAndroidClasses = - listOf( - "com.github.kr328.clash", - ".core.Clash", - ".service.TunService", - ) - - private val sfaClasses = - listOf( - "io.nekohasekai.sfa", - ) - - private val legacySagerNetClasses = - listOf( - "io.nekohasekai.sagernet", - ".fmt.ConfigBuilder", - ) - - private val shadowsocksAndroidClasses = - listOf( - "com.github.shadowsocks", - ".bg.VpnService", - "GuardedProcessPool", - ) - } - - private fun getVPNAppType(packageInfo: PackageInfo): String? { - ZipFile(File(packageInfo.applicationInfo!!.publicSourceDir)).use { packageFile -> - for (packageEntry in packageFile.entries()) { - if (!( - packageEntry.name.startsWith("classes") && - packageEntry.name.endsWith( - ".dex", - ) - ) - ) { - continue - } - if (packageEntry.size > 15000000) { - continue - } - val input = packageFile.getInputStream(packageEntry).buffered() - val dexFile = - try { - DexBackedDexFile.fromInputStream(null, input) - } catch (e: Exception) { - Log.e("VPNScanActivity", "Failed to read dex file", e) - continue - } - for (clazz in dexFile.classes) { - val clazzName = - clazz.type.substring(1, clazz.type.length - 1) - .replace("/", ".") - .replace("$", ".") - for (v2rayNGClass in v2rayNGClasses) { - if (clazzName.contains(v2rayNGClass)) { - return "V2RayNG" - } - } - for (clashForAndroidClass in clashForAndroidClasses) { - if (clazzName.contains(clashForAndroidClass)) { - return "ClashForAndroid" - } - } - for (sfaClass in sfaClasses) { - if (clazzName.contains(sfaClass)) { - return "sing-box" - } - } - for (legacySagerNetClass in legacySagerNetClasses) { - if (clazzName.contains(legacySagerNetClass)) { - return "LegacySagerNet" - } - } - for (shadowsocksAndroidClass in shadowsocksAndroidClasses) { - if (clazzName.contains(shadowsocksAndroidClass)) { - return "shadowsocks-android" - } - } - } - } - return null - } - } - - private fun getVPNCoreType(packageInfo: PackageInfo): VPNCoreType? { - val packageFiles = mutableListOf(packageInfo.applicationInfo!!.publicSourceDir) - packageInfo.applicationInfo!!.splitPublicSourceDirs?.also { - packageFiles.addAll(it) - } - val vpnType = - try { - Libbox.readAndroidVPNType(packageFiles.toStringIterator()) - } catch (ignored: Exception) { - return null - } - return VPNCoreType(vpnType.coreType, vpnType.corePath, vpnType.goVersion) - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt deleted file mode 100644 index 5981730..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt +++ /dev/null @@ -1,294 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.widget.PopupMenu -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding -import io.nekohasekai.sfa.databinding.SheetAddProfileBinding -import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.shareProfile -import io.nekohasekai.sfa.ktx.shareProfileURL -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.ui.profile.EditProfileActivity -import io.nekohasekai.sfa.ui.profile.NewProfileActivity -import io.nekohasekai.sfa.ui.profile.QRScanActivity -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.text.DateFormat -import java.util.Collections - -class ConfigurationFragment : Fragment() { - private var adapter: Adapter? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentConfigurationBinding.inflate(inflater, container, false) - val adapter = Adapter(binding) - this.adapter = adapter - binding.profileList.also { - it.layoutManager = LinearLayoutManager(requireContext()) - it.adapter = adapter - ItemTouchHelper( - object : - ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean { - return adapter.move(viewHolder.adapterPosition, target.adapterPosition) - } - - override fun onSwiped( - viewHolder: RecyclerView.ViewHolder, - direction: Int, - ) { - } - - override fun onSelectedChanged( - viewHolder: RecyclerView.ViewHolder?, - actionState: Int, - ) { - super.onSelectedChanged(viewHolder, actionState) - if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) { - adapter.updateUserOrder() - } - } - }, - ).attachToRecyclerView(it) - } - adapter.reload() - binding.fab.setOnClickListener { - AddProfileDialog().show(childFragmentManager, "add_profile") - } - ProfileManager.registerCallback(this::updateProfiles) - return binding.root - } - - class AddProfileDialog : BottomSheetDialogFragment(R.layout.sheet_add_profile) { - private val importFromFile = - registerForActivityResult(ActivityResultContracts.GetContent(), ::onImportResult) - - private val scanQrCode = - registerForActivityResult(QRScanActivity.Contract(), ::onScanResult) - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - val binding = SheetAddProfileBinding.bind(view) - binding.importFromFile.setOnClickListener { - importFromFile.launch("*/*") - } - binding.scanQrCode.setOnClickListener { - scanQrCode.launch(null) - } - binding.createManually.setOnClickListener { - dismiss() - startActivity(Intent(requireContext(), NewProfileActivity::class.java)) - } - } - - private fun onImportResult(result: Uri?) { - dismiss() - (activity as? MainActivity ?: return).onNewIntent(Intent(Intent.ACTION_VIEW, result)) - } - - private fun onScanResult(result: Intent?) { - dismiss() - (activity as? MainActivity ?: return).onNewIntent(result ?: return) - } - } - - override fun onResume() { - super.onResume() - adapter?.reload() - } - - override fun onDestroyView() { - super.onDestroyView() - ProfileManager.unregisterCallback(this::updateProfiles) - adapter = null - } - - private fun updateProfiles() { - adapter?.reload() - } - - inner class Adapter( - private val parent: FragmentConfigurationBinding, - ) : - RecyclerView.Adapter() { - internal var items: MutableList = mutableListOf() - internal val scope = lifecycleScope - internal val fragmentActivity = requireActivity() - - @SuppressLint("NotifyDataSetChanged") - internal fun reload() { - lifecycleScope.launch(Dispatchers.IO) { - val newItems = ProfileManager.list().toMutableList() - withContext(Dispatchers.Main) { - items = newItems - notifyDataSetChanged() - if (items.isEmpty()) { - parent.statusText.isVisible = true - parent.profileList.isVisible = false - } else if (parent.statusText.isVisible) { - parent.statusText.isVisible = false - parent.profileList.isVisible = true - } - } - } - } - - internal fun move( - from: Int, - to: Int, - ): Boolean { - if (from < to) { - for (i in from until to) { - Collections.swap(items, i, i + 1) - } - } else { - for (i in from downTo to + 1) { - Collections.swap(items, i, i - 1) - } - } - notifyItemMoved(from, to) - return true - } - - @OptIn(DelicateCoroutinesApi::class) - internal fun updateUserOrder() { - items.forEachIndexed { index, profile -> - profile.userOrder = index.toLong() - } - GlobalScope.launch(Dispatchers.IO) { - ProfileManager.update(items) - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): Holder { - return Holder( - this, - ViewConfigutationItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - } - - override fun onBindViewHolder( - holder: Holder, - position: Int, - ) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int { - return items.size - } - } - - class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) : - RecyclerView.ViewHolder(binding.root) { - internal fun bind(profile: Profile) { - binding.profileName.text = profile.name - if (profile.typed.type == TypedProfile.Type.Remote) { - binding.profileLastUpdated.isVisible = true - binding.profileLastUpdated.text = - binding.root.context.getString( - R.string.last_updated_format, - DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated), - ) - } else { - binding.profileLastUpdated.isVisible = false - } - binding.root.setOnClickListener { - val intent = Intent(binding.root.context, EditProfileActivity::class.java) - intent.putExtra("profile_id", profile.id) - it.context.startActivity(intent) - } - binding.moreButton.setOnClickListener { button -> - val popup = PopupMenu(button.context, button) - popup.setForceShowIcon(true) - popup.menuInflater.inflate(R.menu.profile_menu, popup.menu) - if (profile.typed.type != TypedProfile.Type.Remote) { - popup.menu.removeItem(R.id.action_share_url) - } - popup.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_share -> { - adapter.scope.launch(Dispatchers.IO) { - try { - button.context.shareProfile(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - button.context.errorDialogBuilder(e).show() - } - } - } - true - } - - R.id.action_share_url -> { - adapter.scope.launch(Dispatchers.IO) { - try { - adapter.fragmentActivity.shareProfileURL(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - button.context.errorDialogBuilder(e).show() - } - } - } - true - } - - R.id.action_delete -> { - adapter.items.remove(profile) - adapter.notifyItemRemoved(adapterPosition) - adapter.scope.launch(Dispatchers.IO) { - runCatching { - ProfileManager.delete(profile) - } - } - true - } - - else -> false - } - } - popup.show() - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt deleted file mode 100644 index 194a6e2..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt +++ /dev/null @@ -1,187 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.tabs.TabLayoutMediator -import io.nekohasekai.libbox.DeprecatedNoteIterator -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.databinding.FragmentDashboardBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.ui.dashboard.GroupsFragment -import io.nekohasekai.sfa.ui.dashboard.OverviewFragment -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class DashboardFragment : Fragment(R.layout.fragment_dashboard) { - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentDashboardBinding? = null - private var mediator: TabLayoutMediator? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentDashboardBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private val adapter by lazy { Adapter(this) } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - binding.dashboardPager.adapter = adapter - binding.dashboardPager.offscreenPageLimit = Page.values().size - activity.serviceStatus.observe(viewLifecycleOwner) { - when (it) { - Status.Stopped -> { - disablePager() - binding.fab.setImageResource(R.drawable.ic_play_arrow_24) - binding.fab.show() - binding.fab.isEnabled = true - } - - Status.Starting -> { - binding.fab.hide() - } - - Status.Started -> { - checkDeprecatedNotes() - enablePager() - binding.fab.setImageResource(R.drawable.ic_stop_24) - binding.fab.show() - binding.fab.isEnabled = true - } - - Status.Stopping -> { - disablePager() - binding.fab.hide() - } - - else -> {} - } - } - binding.fab.setOnClickListener { - when (activity.serviceStatus.value) { - Status.Stopped -> { - it.isEnabled = false - activity.startService() - } - - Status.Started -> { - BoxService.stop() - } - - else -> {} - } - } - } - - override fun onStart() { - super.onStart() - val activityBinding = activity?.binding ?: return - val binding = binding ?: return - if (mediator != null) return - mediator = - TabLayoutMediator( - activityBinding.dashboardTabLayout, - binding.dashboardPager, - ) { tab, position -> - tab.setText(Page.values()[position].titleRes) - }.apply { attach() } - } - - override fun onDestroyView() { - super.onDestroyView() - mediator?.detach() - mediator = null - binding?.dashboardPager?.adapter = null - binding = null - } - - private fun checkDeprecatedNotes() { - GlobalScope.launch(Dispatchers.IO) { - runCatching { - val notes = Libbox.newStandaloneCommandClient().deprecatedNotes - if (notes.hasNext()) { - withContext(Dispatchers.Main) { - loopShowDeprecatedNotes(notes) - } - } - }.onFailure { - withContext(Dispatchers.Main) { - activity?.errorDialogBuilder(it)?.show() - } - } - } - } - - private fun loopShowDeprecatedNotes(notes: DeprecatedNoteIterator) { - if (notes.hasNext()) { - val note = notes.next() - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setTitle(getString(R.string.service_error_title_deprecated_warning)) - builder.setMessage(note.message()) - builder.setPositiveButton(R.string.ok) { _, _ -> - loopShowDeprecatedNotes(notes) - } - if (!note.migrationLink.isNullOrBlank()) { - builder.setNeutralButton(R.string.service_error_deprecated_warning_documentation) { _, _ -> - requireContext().launchCustomTab(note.migrationLink) - loopShowDeprecatedNotes(notes) - } - } - builder.show() - } - } - - private fun enablePager() { - val activity = activity ?: return - val binding = binding ?: return - activity.binding.dashboardTabLayout.isVisible = true - binding.dashboardPager.isUserInputEnabled = true - } - - private fun disablePager() { - val activity = activity ?: return - val binding = binding ?: return - activity.binding.dashboardTabLayout.isVisible = false - binding.dashboardPager.isUserInputEnabled = false - binding.dashboardPager.setCurrentItem(0, false) - } - - enum class Page( - @StringRes val titleRes: Int, - val fragmentClass: Class, - ) { - Overview(R.string.title_overview, OverviewFragment::class.java), - Groups(R.string.title_groups, GroupsFragment::class.java), - } - - class Adapter(parent: Fragment) : FragmentStateAdapter(parent) { - override fun getItemCount(): Int { - return Page.entries.size - } - - override fun createFragment(position: Int): Fragment { - return Page.entries[position].fragmentClass.getConstructor().newInstance() - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt deleted file mode 100644 index e47ee2d..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt +++ /dev/null @@ -1,198 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.nekohasekai.libbox.LogEntry -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.databinding.FragmentLogBinding -import io.nekohasekai.sfa.databinding.ViewLogTextItemBinding -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.ColorUtils -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.util.LinkedList - -class LogFragment : Fragment(), CommandClient.Handler { - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentLogBinding? = null - private var adapter: Adapter? = null - private val commandClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.Log, this) - private val logList = LinkedList() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentLogBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - binding.logView.layoutManager = LinearLayoutManager(requireContext()) - binding.logView.adapter = Adapter(logList).also { adapter = it } - updateViews() - activity.serviceStatus.observe(viewLifecycleOwner) { - when (it) { - Status.Stopped -> { - binding.fab.setImageResource(R.drawable.ic_play_arrow_24) - binding.fab.show() - binding.statusText.setText(R.string.status_default) - } - - Status.Starting -> { - binding.fab.hide() - binding.statusText.setText(R.string.status_starting) - } - - Status.Started -> { - commandClient.connect() - binding.fab.setImageResource(R.drawable.ic_stop_24) - binding.fab.show() - binding.fab.isEnabled = true - binding.statusText.setText(R.string.status_started) - } - - Status.Stopping -> { - binding.fab.hide() - binding.statusText.setText(R.string.status_stopping) - } - - else -> {} - } - } - binding.fab.setOnClickListener { - when (activity.serviceStatus.value) { - Status.Stopped -> { - it.isEnabled = false - activity.startService() - } - - Status.Started -> { - BoxService.stop() - } - - else -> {} - } - } - } - - private fun updateViews( - removeLen: Int = 0, - insertLen: Int = 0, - ) { - val activity = activity ?: return - val logAdapter = adapter ?: return - val binding = binding ?: return - if (logList.isEmpty()) { - binding.logView.isVisible = false - binding.statusText.isVisible = true - } else if (!binding.logView.isVisible) { - binding.logView.isVisible = true - binding.statusText.isVisible = false - } - if (insertLen == 0) { - logAdapter.notifyDataSetChanged() - if (logList.size > 0) { - binding.logView.scrollToPosition(logList.size - 1) - } - } else { - if (logList.size == 300) { - logAdapter.notifyItemRangeRemoved(0, removeLen) - } - logAdapter.notifyItemRangeInserted(logList.size - insertLen, insertLen) - binding.logView.scrollToPosition(logList.size - 1) - } - } - - override fun onDestroyView() { - super.onDestroyView() - commandClient.disconnect() - binding = null - adapter = null - } - - override fun onConnected() { - lifecycleScope.launch(Dispatchers.Main) { - logList.clear() - updateViews() - } - } - - override fun clearLogs() { - lifecycleScope.launch(Dispatchers.Main) { - logList.clear() - updateViews() - } - } - - private var defaultLogLevel = 0 - - override fun setDefaultLogLevel(level: Int) { - defaultLogLevel = level - } - - override fun appendLogs(messageList: List) { - val messageList = messageList.filter { it.level <= defaultLogLevel } - lifecycleScope.launch(Dispatchers.Main) { - val messageLen = messageList.size - val removeLen = logList.size + messageLen - 300 - logList.addAll(messageList.map { it.message }) - if (removeLen > 0) { - repeat(removeLen) { - logList.removeFirst() - } - } - updateViews(removeLen, messageLen) - } - } - - class Adapter(private val logList: LinkedList) : - RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): LogViewHolder { - return LogViewHolder( - ViewLogTextItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ), - ) - } - - override fun onBindViewHolder( - holder: LogViewHolder, - position: Int, - ) { - holder.bind(logList.getOrElse(position) { "" }) - } - - override fun getItemCount(): Int { - return logList.size - } - } - - class LogViewHolder(private val binding: ViewLogTextItemBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(message: String) { - binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message) - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt deleted file mode 100644 index ccea0a0..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.BuildConfig -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.EnabledType -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.FragmentSettingsBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.ktx.setSimpleItems -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.ui.debug.DebugActivity -import io.nekohasekai.sfa.ui.profileoverride.ProfileOverrideActivity -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class SettingsFragment : Fragment() { - private lateinit var binding: FragmentSettingsBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - binding = FragmentSettingsBinding.inflate(inflater, container, false) - onCreate() - return binding.root - } - - @RequiresApi(Build.VERSION_CODES.M) - private val requestIgnoreBatteryOptimizations = - registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)) { - binding.backgroundPermissionCard.isGone = true - } - } - - @SuppressLint("BatteryLife") - private fun onCreate() { - val activity = activity as MainActivity? ?: return - val binding = binding ?: return - binding.versionText.text = Libbox.version() - binding.clearButton.setOnClickListener { - lifecycleScope.launch(Dispatchers.IO) { - activity.getExternalFilesDir(null)?.deleteRecursively() - reloadSettings() - } - } - binding.useComposeUIEnabled.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.useComposeUI = newValue - if (newValue) { - withContext(Dispatchers.Main) { - // Restart with Compose UI - val intent = Intent(requireContext(), Class.forName("io.nekohasekai.sfa.compose.ComposeActivity")) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - activity.finish() - } - } - } - } - binding.checkUpdateEnabled.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.checkUpdateEnabled = newValue - } - } - binding.checkUpdateButton.setOnClickListener { - Vendor.checkUpdate(activity, true) - } - binding.openPrivacyPolicyButton.setOnClickListener { - activity.launchCustomTab("https://sing-box.sagernet.org/clients/privacy/") - } - binding.disableMemoryLimit.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.disableMemoryLimit = !newValue - } - } - binding.dynamicNotificationEnabled.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.dynamicNotification = newValue - } - } - - binding.dontKillMyAppButton.setOnClickListener { - it.context.launchCustomTab("https://dontkillmyapp.com/") - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding.requestIgnoreBatteryOptimizationsButton.setOnClickListener { - requestIgnoreBatteryOptimizations.launch( - Intent( - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:${Application.application.packageName}"), - ), - ) - } - } - binding.configureOverridesButton.setOnClickListener { - startActivity(Intent(requireContext(), ProfileOverrideActivity::class.java)) - } - binding.openDebugButton.setOnClickListener { - startActivity(Intent(requireContext(), DebugActivity::class.java)) - } - binding.startSponserButton.setOnClickListener { - activity.launchCustomTab("https://sekai.icu/sponsors/") - } - lifecycleScope.launch(Dispatchers.IO) { - reloadSettings() - } - } - - private suspend fun reloadSettings() { - val activity = activity ?: return - val binding = binding ?: return - val dataSize = - Libbox.formatBytes( - (activity.getExternalFilesDir(null) ?: activity.filesDir) - .walkTopDown().filter { it.isFile }.map { it.length() }.sum(), - ) - val checkUpdateEnabled = Settings.checkUpdateEnabled - val removeBackgroundPermissionPage = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName) - } else { - true - } - val dynamicNotification = Settings.dynamicNotification - val useComposeUI = Settings.useComposeUI - withContext(Dispatchers.Main) { - binding.dataSizeText.text = dataSize - binding.checkUpdateEnabled.text = - EnabledType.from(checkUpdateEnabled).getString(requireContext()) - binding.checkUpdateEnabled.setSimpleItems(R.array.enabled) - binding.disableMemoryLimit.text = - EnabledType.from(!Settings.disableMemoryLimit).getString(requireContext()) - binding.disableMemoryLimit.setSimpleItems(R.array.enabled) - binding.backgroundPermissionCard.isGone = removeBackgroundPermissionPage - binding.dynamicNotificationEnabled.text = - EnabledType.from(dynamicNotification).getString(requireContext()) - binding.dynamicNotificationEnabled.setSimpleItems(R.array.enabled) - binding.experimentalFeaturesCard.isVisible = BuildConfig.DEBUG - binding.useComposeUIEnabled.text = - EnabledType.from(useComposeUI).getString(requireContext()) - binding.useComposeUIEnabled.setSimpleItems(R.array.enabled) - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt deleted file mode 100644 index a2e1d42..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt +++ /dev/null @@ -1,203 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.UpdateProfileWork -import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter -import io.nekohasekai.sfa.constant.EnabledType -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.setSimpleItems -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.utils.HTTPClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.Date - -class EditProfileActivity : AbstractActivity() { - private lateinit var profile: Profile - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_edit_profile) - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - loadProfile() - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it) - .setPositiveButton(R.string.ok) { _, _ -> finish() } - .show() - } - } - } - } - - private suspend fun loadProfile() { - delay(200L) - val profileId = intent.getLongExtra("profile_id", -1L) - if (profileId == -1L) error("invalid arguments") - profile = ProfileManager.get(profileId) ?: error("invalid arguments") - withContext(Dispatchers.Main) { - binding.name.text = profile.name - binding.name.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - try { - profile.name = it - ProfileManager.update(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - } - } - binding.type.text = profile.typed.type.getString(this@EditProfileActivity) - binding.editButton.setOnClickListener { - startActivity( - Intent( - this@EditProfileActivity, - EditProfileContentActivity::class.java, - ).apply { - putExtra("profile_id", profile.id) - }, - ) - } - when (profile.typed.type) { - TypedProfile.Type.Local -> { - binding.editButton.isVisible = true - binding.remoteFields.isVisible = false - } - - TypedProfile.Type.Remote -> { - binding.editButton.isVisible = false - binding.remoteFields.isVisible = true - binding.remoteURL.text = profile.typed.remoteURL - binding.lastUpdated.text = - RelativeTimeFormatter.format(this@EditProfileActivity, profile.typed.lastUpdated) - binding.autoUpdate.text = - EnabledType.from(profile.typed.autoUpdate) - .getString(this@EditProfileActivity) - binding.autoUpdate.setSimpleItems(R.array.enabled) - binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate - binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString() - } - } - binding.remoteURL.addTextChangedListener(this@EditProfileActivity::updateRemoteURL) - binding.autoUpdate.addTextChangedListener(this@EditProfileActivity::updateAutoUpdate) - binding.autoUpdateInterval.addTextChangedListener(this@EditProfileActivity::updateAutoUpdateInterval) - binding.updateButton.setOnClickListener(this@EditProfileActivity::updateProfile) - binding.profileLayout.isVisible = true - binding.progressView.isVisible = false - } - } - - private fun updateRemoteURL(newValue: String) { - profile.typed.remoteURL = newValue - updateProfile() - } - - private fun updateAutoUpdate(newValue: String) { - val boolValue = EnabledType.valueOf(this, newValue).boolValue - if (profile.typed.autoUpdate == boolValue) { - return - } - binding.autoUpdateInterval.isVisible = boolValue - profile.typed.autoUpdate = boolValue - if (boolValue) { - lifecycleScope.launch(Dispatchers.IO) { - UpdateProfileWork.reconfigureUpdater() - } - } - updateProfile() - } - - private fun updateAutoUpdateInterval(newValue: String) { - if (newValue.isBlank()) { - binding.autoUpdateInterval.error = getString(R.string.profile_input_required) - return - } - val intValue = - try { - newValue.toInt() - } catch (e: Exception) { - binding.autoUpdateInterval.error = e.localizedMessage - return - } - if (intValue < 15) { - binding.autoUpdateInterval.error = - getString(R.string.profile_auto_update_interval_minimum_hint) - return - } - binding.autoUpdateInterval.error = null - profile.typed.autoUpdateInterval = intValue - updateProfile() - } - - private fun updateProfile() { - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - delay(200L) - try { - ProfileManager.update(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - withContext(Dispatchers.Main) { - binding.progressView.isVisible = false - } - } - } - - @Suppress("UNUSED_PARAMETER") - private fun updateProfile(view: View) { - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - var selectedProfileUpdated = false - try { - val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } - Libbox.checkConfig(content) - val file = File(profile.typed.path) - if (file.readText() != content) { - File(profile.typed.path).writeText(content) - if (profile.id == Settings.selectedProfile) { - selectedProfileUpdated = true - } - } - profile.typed.lastUpdated = Date() - ProfileManager.update(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - withContext(Dispatchers.Main) { - binding.lastUpdated.text = - RelativeTimeFormatter.format(this@EditProfileActivity, profile.typed.lastUpdated) - binding.progressView.isVisible = false - } - if (selectedProfileUpdated) { - runCatching { - Libbox.newStandaloneCommandClient().serviceReload() - } - } - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt deleted file mode 100644 index 28c6f08..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt +++ /dev/null @@ -1,138 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.lifecycleScope -import com.blacksquircle.ui.language.json.JsonLanguage -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.unwrap -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -class EditProfileContentActivity : AbstractActivity() { - private var profile: Profile? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_edit_configuration) - binding.editor.language = JsonLanguage() - loadConfiguration() - } - - private fun loadConfiguration() { - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - loadConfiguration0() - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it) - .setPositiveButton(R.string.ok) { _, _ -> finish() } - .show() - } - } - } - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.edit_configutation_menu, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_undo -> { - if (binding.editor.canUndo()) binding.editor.undo() - return true - } - - R.id.action_redo -> { - if (binding.editor.canRedo()) binding.editor.redo() - return true - } - - R.id.action_check -> { - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - Libbox.checkConfig(binding.editor.text.toString()) - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it).show() - } - } - withContext(Dispatchers.Main) { - delay(200) - binding.progressView.isInvisible = true - } - } - return true - } - - R.id.action_format -> { - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - val content = Libbox.formatConfig(binding.editor.text.toString()).unwrap - if (binding.editor.text.toString() != content) { - withContext(Dispatchers.Main) { - binding.editor.setTextContent(content) - } - } - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it).show() - } - } - } - return true - } - } - return super.onOptionsItemSelected(item) - } - - private suspend fun loadConfiguration0() { - delay(200L) - - val profileId = intent.getLongExtra("profile_id", -1L) - if (profileId == -1L) error("invalid arguments") - val profile = ProfileManager.get(profileId) ?: error("invalid arguments") - this.profile = profile - val content = File(profile.typed.path).readText() - withContext(Dispatchers.Main) { - binding.editor.setTextContent(content) - binding.editor.addTextChangedListener { - binding.progressView.isVisible = true - val newContent = it.toString() - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - File(profile.typed.path).writeText(newContent) - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } - } - withContext(Dispatchers.Main) { - delay(200L) - binding.progressView.isInvisible = true - } - } - } - binding.progressView.isInvisible = true - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt deleted file mode 100644 index 62051f6..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt +++ /dev/null @@ -1,221 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.content.Context -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.EnabledType -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.ActivityAddProfileBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.removeErrorIfNotEmpty -import io.nekohasekai.sfa.ktx.showErrorIfEmpty -import io.nekohasekai.sfa.ktx.startFilesForResult -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.utils.HTTPClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.InputStream -import java.util.Date - -class NewProfileActivity : AbstractActivity() { - enum class FileSource( - @StringRes val formattedRes: Int, - ) { - CreateNew(R.string.profile_source_create_new), - Import(R.string.profile_source_import), - ; - - fun formatted(context: Context): String { - return context.getString(formattedRes) - } - } - - private val importFile = - registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI -> - if (fileURI != null) { - binding.sourceURL.editText?.setText(fileURI.toString()) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_new_profile) - - intent.getStringExtra("importName")?.also { importName -> - intent.getStringExtra("importURL")?.also { importURL -> - binding.name.editText?.setText(importName) - binding.type.text = TypedProfile.Type.Remote.getString(this) - binding.remoteURL.editText?.setText(importURL) - binding.localFields.isVisible = false - binding.remoteFields.isVisible = true - binding.autoUpdateInterval.text = "60" - } - } - - binding.name.removeErrorIfNotEmpty() - binding.type.addTextChangedListener { - when (it) { - TypedProfile.Type.Local.getString(this) -> { - binding.localFields.isVisible = true - binding.remoteFields.isVisible = false - } - - TypedProfile.Type.Remote.getString(this) -> { - binding.localFields.isVisible = false - binding.remoteFields.isVisible = true - if (binding.autoUpdateInterval.text.toIntOrNull() == null) { - binding.autoUpdateInterval.text = "60" - } - } - } - } - binding.fileSourceMenu.addTextChangedListener { - when (it) { - FileSource.CreateNew.formatted(this) -> { - binding.importFileButton.isVisible = false - binding.sourceURL.isVisible = false - } - - FileSource.Import.formatted(this) -> { - binding.importFileButton.isVisible = true - binding.sourceURL.isVisible = true - } - } - } - binding.importFileButton.setOnClickListener { - startFilesForResult(importFile, "application/json") - } - binding.createProfile.setOnClickListener(this::createProfile) - binding.autoUpdateInterval.addTextChangedListener(this::updateAutoUpdateInterval) - } - - private fun createProfile( - @Suppress("UNUSED_PARAMETER") view: View, - ) { - if (binding.name.showErrorIfEmpty()) { - return - } - when (binding.type.text) { - TypedProfile.Type.Local.getString(this) -> { - when (binding.fileSourceMenu.text) { - FileSource.Import.formatted(this) -> { - if (binding.sourceURL.showErrorIfEmpty()) { - return - } - } - } - } - - TypedProfile.Type.Remote.getString(this) -> { - if (binding.remoteURL.showErrorIfEmpty()) { - return - } - } - } - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - createProfile0() - }.onFailure { e -> - withContext(Dispatchers.Main) { - binding.progressView.isVisible = false - errorDialogBuilder(e).show() - } - } - } - } - - private suspend fun createProfile0() { - val typedProfile = TypedProfile() - val profile = Profile(name = binding.name.text, typed = typedProfile) - profile.userOrder = ProfileManager.nextOrder() - val fileID = ProfileManager.nextFileID() - val configDirectory = File(filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "$fileID.json") - typedProfile.path = configFile.path - - when (binding.type.text) { - TypedProfile.Type.Local.getString(this) -> { - typedProfile.type = TypedProfile.Type.Local - - when (binding.fileSourceMenu.text) { - FileSource.CreateNew.formatted(this) -> { - configFile.writeText("{}") - } - - FileSource.Import.formatted(this) -> { - val sourceURL = binding.sourceURL.text - val content = - if (sourceURL.startsWith("content://")) { - val inputStream = - contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream - inputStream.use { it.bufferedReader().readText() } - } else if (sourceURL.startsWith("file://")) { - File(sourceURL).readText() - } else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) { - HTTPClient().use { it.getString(sourceURL) } - } else { - error("unsupported source: $sourceURL") - } - Libbox.checkConfig(content) - configFile.writeText(content) - } - } - } - - TypedProfile.Type.Remote.getString(this) -> { - typedProfile.type = TypedProfile.Type.Remote - val remoteURL = binding.remoteURL.text - val content = HTTPClient().use { it.getString(remoteURL) } - Libbox.checkConfig(content) - configFile.writeText(content) - typedProfile.remoteURL = remoteURL - typedProfile.lastUpdated = Date() - typedProfile.autoUpdate = - EnabledType.valueOf(this, binding.autoUpdate.text).boolValue - binding.autoUpdateInterval.text.toIntOrNull()?.also { - typedProfile.autoUpdateInterval = it - } - } - } - ProfileManager.create(profile) - withContext(Dispatchers.Main) { - binding.progressView.isVisible = false - finish() - } - } - - private fun updateAutoUpdateInterval(newValue: String) { - if (newValue.isBlank()) { - binding.autoUpdateInterval.error = getString(R.string.profile_input_required) - return - } - val intValue = - try { - newValue.toInt() - } catch (e: Exception) { - binding.autoUpdateInterval.error = e.localizedMessage - return - } - if (intValue < 15) { - binding.autoUpdateInterval.error = - getString(R.string.profile_auto_update_interval_minimum_hint) - return - } - binding.autoUpdateInterval.error = null - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt deleted file mode 100644 index dc7d43b..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.nekohasekai.sfa.ui.profileoverride - -import android.content.Intent -import android.os.Bundle -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.ActivityConfigOverrideBinding -import io.nekohasekai.sfa.ui.shared.AbstractActivity - -class ProfileOverrideActivity : - AbstractActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.profile_override) - binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled - binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> - Settings.perAppProxyEnabled = isChecked - binding.configureAppListButton.isEnabled = isChecked - } - binding.configureAppListButton.isEnabled = binding.switchPerAppProxy.isChecked - - binding.configureAppListButton.setOnClickListener { - startActivity(Intent(this, PerAppProxyActivity::class.java)) - } - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt index d83c2e2..519cc55 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt @@ -14,7 +14,6 @@ import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.color.DynamicColors import io.nekohasekai.sfa.R import io.nekohasekai.sfa.ktx.getAttrColor -import io.nekohasekai.sfa.ui.MainActivity import io.nekohasekai.sfa.utils.MIUIUtils import java.lang.reflect.ParameterizedType @@ -56,17 +55,15 @@ abstract class AbstractActivity : AppCompatActivity() { window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) } - if (this !is MainActivity) { - supportActionBar?.setHomeAsUpIndicator( - AppCompatResources.getDrawable( - this@AbstractActivity, - R.drawable.ic_arrow_back_24, - )!!.apply { - setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) - }, - ) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } + supportActionBar?.setHomeAsUpIndicator( + AppCompatResources.getDrawable( + this@AbstractActivity, + R.drawable.ic_arrow_back_24, + )!!.apply { + setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) + }, + ) + supportActionBar?.setDisplayHomeAsUpEnabled(true) } override fun onOptionsItemSelected(item: MenuItem): Boolean { diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt deleted file mode 100644 index 4c01676..0000000 --- a/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.nekohasekai.sfa.ui.shared - -import android.graphics.Bitmap -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import io.nekohasekai.sfa.databinding.FragmentQrcodeDialogBinding - -class QRCodeDialog(private val bitmap: Bitmap) : - BottomSheetDialogFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = FragmentQrcodeDialogBinding.inflate(inflater, container, false) - val behavior = BottomSheetBehavior.from(binding.qrcodeLayout) - behavior.state = BottomSheetBehavior.STATE_EXPANDED - binding.qrCode.setImageBitmap(bitmap) - return binding.root - } -} diff --git a/app/src/main/res/layout/activity_add_profile.xml b/app/src/main/res/layout/activity_add_profile.xml deleted file mode 100644 index 4657c24..0000000 --- a/app/src/main/res/layout/activity_add_profile.xml +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_config_override.xml b/app/src/main/res/layout/activity_config_override.xml deleted file mode 100644 index 33448b0..0000000 --- a/app/src/main/res/layout/activity_config_override.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - -