commit 7736e1e644e9062282b41065d4da5515a8417c97 Author: 世界 Date: Fri Dec 2 14:17:47 2022 +0800 Init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ff1684 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/libs/ \ No newline at end of file diff --git a/.run/sing-box-for-android [_app_appCenterAssembleAndUploadRelease].run.xml b/.run/sing-box-for-android [_app_appCenterAssembleAndUploadRelease].run.xml new file mode 100644 index 0000000..1a3fb81 --- /dev/null +++ b/.run/sing-box-for-android [_app_appCenterAssembleAndUploadRelease].run.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.run/sing-box-for-android [_app_assembleRelease].run.xml b/.run/sing-box-for-android [_app_assembleRelease].run.xml new file mode 100644 index 0000000..782b349 --- /dev/null +++ b/.run/sing-box-for-android [_app_assembleRelease].run.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.run/sing-box-for-android [_app_installDebug].run.xml b/.run/sing-box-for-android [_app_installDebug].run.xml new file mode 100644 index 0000000..6a11b79 --- /dev/null +++ b/.run/sing-box-for-android [_app_installDebug].run.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..175f350 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30bd9bd --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# SFA + +Experimental Android client for sing-box, the universal proxy platform. + +## Documentation + +https://sing-box.sagernet.org/installation/clients/sfa/ + +## License + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. +``` + +Under the license, that forks of the app are not allowed to be listed on F-Droid or other app stores +under the original name. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..3eebdaf --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,131 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'com.google.devtools.ksp' +} + +android { + namespace 'io.nekohasekai.sfa' + compileSdk 33 + + ksp { + arg("room.incremental", "true") + arg("room.schemaLocation", "$projectDir/schemas") + } + + defaultConfig { + applicationId "io.nekohasekai.sfa" + minSdk 21 + targetSdk 33 + versionCode getProps("VERSION_CODE").toInteger() + versionName getProps("VERSION_NAME") + } + + signingConfigs { + release { + storeFile file("release.keystore") + storePassword getProps("KEYSTORE_PASS") + keyAlias getProps("ALIAS_NAME") + keyPassword getProps("ALIAS_PASS") + } + } + + buildTypes { + debug { + if (getProps("KEYSTORE_PASS") != "") { + signingConfig signingConfigs.release + } + buildConfigField("String", "APPCENTER_SECRET", "\"" + getProps("APPCENTER_SECRET") + "\"") + } + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + buildConfigField("String", "APPCENTER_SECRET", "\"" + getProps("APPCENTER_SECRET") + "\"") + } + } + + splits { + abi { + enable true + universalApk true + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildFeatures { + viewBinding true + aidl true + } +} + +dependencies { + implementation(fileTree('libs')) + + implementation 'androidx.core:core-ktx:1.10.1' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.9.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' + implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.6.0' + implementation 'androidx.room:room-runtime:2.5.2' + implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' + implementation 'androidx.preference:preference-ktx:1.2.0' + ksp 'androidx.room:room-compiler:2.5.2' + implementation 'androidx.work:work-runtime-ktx:2.8.1' + implementation 'androidx.browser:browser:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' + + // DO NOT UPDATE (minSdkVersion updated) + implementation 'com.blacksquircle.ui:editorkit:2.2.0' + implementation 'com.blacksquircle.ui:language-json:2.2.0' + + implementation 'com.microsoft.appcenter:appcenter-analytics:5.0.1' + implementation 'com.microsoft.appcenter:appcenter-crashes:5.0.1' + implementation 'com.microsoft.appcenter:appcenter-distribute:5.0.1' +} + +if (getProps("APPCENTER_TOKEN") != "") { + apply plugin: "com.betomorrow.appcenter" + appcenter { + apiToken = getProps("APPCENTER_TOKEN") + ownerName = getProps("APPCENTER_OWNER") + distributionGroups = [getProps("APPCENTER_GROUP")] + releaseNotes = getProps("RELEASE_NOTES") + notifyTesters = true + apps { + release { + appName = getProps("APPCENTER_APP_NAME") + } + } + } +} + +tasks.withType(KotlinCompile.class).configureEach { + kotlinOptions { + jvmTarget = "1.8" + } +} + +def getProps(String propName) { + def propsFile = rootProject.file('local.properties') + if (propsFile.exists()) { + def props = new Properties() + props.load(new FileInputStream(propsFile)) + String value = props[propName] + if (value == null) { + return ""; + } + return value + } else { + return ""; + } +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/release.keystore b/app/release.keystore new file mode 100644 index 0000000..ab1a9fb Binary files /dev/null and b/app/release.keystore differ diff --git a/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json new file mode 100644 index 0000000..ec8282d --- /dev/null +++ b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "b7bfa362ec191b0a18660e615da81e46", + "entities": [ + { + "tableName": "profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `typed` BLOB NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "typed", + "columnName": "typed", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b7bfa362ec191b0a18660e615da81e46')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.nekohasekai.sfa.database.preference.KeyValueDatabase/1.json b/app/schemas/io.nekohasekai.sfa.database.preference.KeyValueDatabase/1.json new file mode 100644 index 0000000..8321528 --- /dev/null +++ b/app/schemas/io.nekohasekai.sfa.database.preference.KeyValueDatabase/1.json @@ -0,0 +1,46 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c20dc7fa2a9489b6f52aafe18f86ecea", + "entities": [ + { + "tableName": "KeyValueEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "valueType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c20dc7fa2a9489b6f52aafe18f86ecea')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a723722 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/aidl/io/nekohasekai/sfa/aidl/IService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/aidl/IService.aidl new file mode 100644 index 0000000..089be7f --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/aidl/IService.aidl @@ -0,0 +1,9 @@ +package io.nekohasekai.sfa.aidl; + +import io.nekohasekai.sfa.aidl.IServiceCallback; + +interface IService { + int getStatus(); + void registerCallback(in IServiceCallback callback); + oneway void unregisterCallback(in IServiceCallback callback); +} \ No newline at end of file diff --git a/app/src/main/aidl/io/nekohasekai/sfa/aidl/IServiceCallback.aidl b/app/src/main/aidl/io/nekohasekai/sfa/aidl/IServiceCallback.aidl new file mode 100644 index 0000000..ddfd27a --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/aidl/IServiceCallback.aidl @@ -0,0 +1,8 @@ +package io.nekohasekai.sfa.aidl; + +interface IServiceCallback { + void onServiceStatusChanged(int status); + void onServiceAlert(int type, String message); + void onServiceWriteLog(String message); + void onServiceResetLogs(in List messages); +} \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..fda41a2 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt new file mode 100644 index 0000000..d7e25b5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -0,0 +1,40 @@ +package io.nekohasekai.sfa + +import android.app.Application +import android.app.NotificationManager +import android.content.Context +import android.net.ConnectivityManager +import androidx.core.content.getSystemService +import go.Seq +import io.nekohasekai.sfa.bg.UpdateProfileWork +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import io.nekohasekai.sfa.Application as BoxApplication + +class Application : Application() { + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + + application = this + } + + override fun onCreate() { + super.onCreate() + + Seq.setContext(this) + + GlobalScope.launch(Dispatchers.IO) { + UpdateProfileWork.reconfigureUpdater() + } + } + + companion object { + lateinit var application: BoxApplication + val notification by lazy { application.getSystemService()!! } + val connectivity by lazy { application.getSystemService()!! } + val packageManager by lazy { application.packageManager } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt new file mode 100644 index 0000000..bca64a6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt @@ -0,0 +1,30 @@ +package io.nekohasekai.sfa.bg + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { + } + + else -> return + } + GlobalScope.launch(Dispatchers.IO) { + if (Settings.startedByUser) { + withContext(Dispatchers.Main) { + BoxService.start() + } + } + } + } + +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt new file mode 100644 index 0000000..5c756ca --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -0,0 +1,264 @@ +package io.nekohasekai.sfa.bg + +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.IBinder +import android.os.ParcelFileDescriptor +import androidx.core.content.ContextCompat +import androidx.lifecycle.MutableLiveData +import go.Seq +import io.nekohasekai.libbox.BoxService +import io.nekohasekai.libbox.CommandServer +import io.nekohasekai.libbox.CommandServerHandler +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.PProfServer +import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.constant.Action +import io.nekohasekai.sfa.constant.Alert +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Profiles +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.File + +class BoxService( + private val service: Service, + private val platformInterface: PlatformInterface +) : CommandServerHandler { + + companion object { + + private var initializeOnce = false + private fun initialize() { + if (initializeOnce) return + val baseDir = Application.application.getExternalFilesDir(null) ?: return + baseDir.mkdirs() + val tempDir = Application.application.cacheDir + tempDir.mkdirs() + Libbox.setup(baseDir.path, tempDir.path, -1, -1) + Libbox.redirectStderr(File(baseDir, "stderr.log").path) + initializeOnce = true + return + } + + fun start() { + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(Application.application, Settings.serviceClass()) + } + } + ContextCompat.startForegroundService(Application.application, intent) + } + + fun stop() { + Application.application.sendBroadcast( + Intent(Action.SERVICE_CLOSE).setPackage( + Application.application.packageName + ) + ) + } + } + + var fileDescriptor: ParcelFileDescriptor? = null + + private val status = MutableLiveData(Status.Stopped) + private val binder = ServiceBinder(status) + private val notification = ServiceNotification(service) + private var boxService: BoxService? = null + private var commandServer: CommandServer? = null + private var pprofServer: PProfServer? = null + private var receiverRegistered = false + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Action.SERVICE_CLOSE -> { + stopService() + } + } + } + } + + private fun startCommandServer() { + val commandServer = + CommandServer(Application.application.filesDir.absolutePath, this) + commandServer.start() + this.commandServer = commandServer + } + + private suspend fun startService() { + initialize() + try { + val selectedProfileId = Settings.selectedProfile + if (selectedProfileId == -1L) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + + val profile = Profiles.get(selectedProfileId) + if (profile == null) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + + val content = File(profile.typed.path).readText() + if (content.isBlank()) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + + withContext(Dispatchers.Main) { + binder.broadcast { + it.onServiceResetLogs(listOf()) + } + } + + DefaultNetworkMonitor.start() + Libbox.registerLocalDNSTransport(LocalResolver) + + val newService = try { + Libbox.newService(content, platformInterface) + } catch (e: Exception) { + stopAndAlert(Alert.CreateService, e.message) + return + } + + newService.start() + boxService = newService + + status.postValue(Status.Started) + } catch (e: Exception) { + stopAndAlert(Alert.StartService, e.message) + return + } + } + + override fun serviceReload() { + GlobalScope.launch(Dispatchers.IO) { + val pfd = fileDescriptor + if (pfd != null) { + pfd.close() + fileDescriptor = null + } + boxService?.apply { + runCatching { + close() + }.onFailure { + writeLog("service: error when closing: $it") + } + Seq.destroyRef(refnum) + } + boxService = null + startService() + } + } + + override fun serviceStop() { + + } + + private fun stopService() { + if (status.value != Status.Started) return + status.value = Status.Stopping + if (receiverRegistered) { + service.unregisterReceiver(receiver) + receiverRegistered = false + } + notification.close() + GlobalScope.launch(Dispatchers.IO) { + val pfd = fileDescriptor + if (pfd != null) { + pfd.close() + fileDescriptor = null + } + boxService?.apply { + runCatching { + close() + }.onFailure { + writeLog("service: error when closing: $it") + } + Seq.destroyRef(refnum) + } + boxService = null + Libbox.registerLocalDNSTransport(null) + DefaultNetworkMonitor.stop() + + commandServer?.apply { + close() + Seq.destroyRef(refnum) + } + commandServer = null + Settings.startedByUser = false + withContext(Dispatchers.Main) { + status.value = Status.Stopped + service.stopSelf() + } + } + } + + private suspend fun stopAndAlert(type: Alert, message: String? = null) { + Settings.startedByUser = false + withContext(Dispatchers.Main) { + if (receiverRegistered) { + service.unregisterReceiver(receiver) + receiverRegistered = false + } + notification.close() + binder.broadcast { callback -> + callback.onServiceAlert(type.ordinal, message) + } + status.value = Status.Stopped + } + } + + fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (status.value != Status.Stopped) return Service.START_NOT_STICKY + status.value = Status.Starting + + if (!receiverRegistered) { + service.registerReceiver(receiver, IntentFilter().apply { + addAction(Action.SERVICE_CLOSE) + }) + receiverRegistered = true + } + + notification.show() + GlobalScope.launch(Dispatchers.IO) { + Settings.startedByUser = true + try { + startCommandServer() + } catch (e: Exception) { + stopAndAlert(Alert.StartCommandServer, e.message) + return@launch + } + startService() + } + return Service.START_NOT_STICKY + } + + fun onBind(intent: Intent): IBinder { + return binder + } + + fun onDestroy() { + binder.close() + } + + fun onRevoke() { + stopService() + } + + fun writeLog(message: String) { + binder.broadcast { + it.onServiceWriteLog(message) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt new file mode 100644 index 0000000..6f5fb21 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt @@ -0,0 +1,196 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package io.nekohasekai.sfa.bg + +import android.annotation.TargetApi +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.runBlocking +import java.net.UnknownHostException + +object DefaultNetworkListener { + private sealed class NetworkMessage { + class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() + class Get : NetworkMessage() { + val response = CompletableDeferred() + } + + class Stop(val key: Any) : NetworkMessage() + + class Put(val network: Network) : NetworkMessage() + class Update(val network: Network) : NetworkMessage() + class Lost(val network: Network) : NetworkMessage() + } + + private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) { + val listeners = mutableMapOf Unit>() + var network: Network? = null + val pendingRequests = arrayListOf() + for (message in channel) when (message) { + is NetworkMessage.Start -> { + if (listeners.isEmpty()) register() + listeners[message.key] = message.listener + if (network != null) message.listener(network) + } + + is NetworkMessage.Get -> { + check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } + if (network == null) pendingRequests += message else message.response.complete( + network + ) + } + + is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty + listeners.remove(message.key) != null && listeners.isEmpty() + ) { + network = null + unregister() + } + + is NetworkMessage.Put -> { + network = message.network + pendingRequests.forEach { it.response.complete(message.network) } + pendingRequests.clear() + listeners.values.forEach { it(network) } + } + + is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach { + it( + network + ) + } + + is NetworkMessage.Lost -> if (network == message.network) { + network = null + listeners.values.forEach { it(null) } + } + } + } + + suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send( + NetworkMessage.Start( + key, + listener + ) + ) + + suspend fun get() = if (fallback) @TargetApi(23) { + Application.connectivity.activeNetwork + ?: throw UnknownHostException() // failed to listen, return current if available + } else NetworkMessage.Get().run { + networkActor.send(this) + response.await() + } + + suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) + + // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 + private object Callback : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = runBlocking { + networkActor.send( + NetworkMessage.Put( + network + ) + ) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + // it's a good idea to refresh capabilities + runBlocking { networkActor.send(NetworkMessage.Update(network)) } + } + + override fun onLost(network: Network) = runBlocking { + networkActor.send( + NetworkMessage.Lost( + network + ) + ) + } + } + + private var fallback = false + private val request = NetworkRequest.Builder().apply { + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs + removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } + }.build() + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: + * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e + * + * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that + * satisfies default network capabilities but only THE default network. Unfortunately, we need to have + * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork. + * + * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887 + */ + private fun register() { + when (Build.VERSION.SDK_INT) { + in 31..Int.MAX_VALUE -> @TargetApi(31) { + Application.connectivity.registerBestMatchingNetworkCallback( + request, + Callback, + mainHandler + ) + } + + in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN + Application.connectivity.requestNetwork(request, Callback, mainHandler) + } + + in 26 until 28 -> @TargetApi(26) { + Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) + } + + in 24 until 26 -> @TargetApi(24) { + Application.connectivity.registerDefaultNetworkCallback(Callback) + } + + else -> try { + fallback = false + Application.connectivity.requestNetwork(request, Callback) + } catch (e: RuntimeException) { + fallback = + true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 + } + } + } + + private fun unregister() = Application.connectivity.unregisterNetworkCallback(Callback) +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt new file mode 100644 index 0000000..8b918ff --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sfa.bg + +import android.net.Network +import android.os.Build +import io.nekohasekai.libbox.InterfaceUpdateListener +import io.nekohasekai.sfa.Application + +object DefaultNetworkMonitor { + + var defaultNetwork: Network? = null + private var listener: InterfaceUpdateListener? = null + + suspend fun start() { + DefaultNetworkListener.start(this) { + defaultNetwork = it + checkDefaultInterfaceUpdate(it) + } + defaultNetwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Application.connectivity.activeNetwork + } else { + DefaultNetworkListener.get() + } + } + + suspend fun stop() { + DefaultNetworkListener.stop(this) + } + + fun setListener(listener: InterfaceUpdateListener?) { + this.listener = listener + checkDefaultInterfaceUpdate(defaultNetwork) + } + + private fun checkDefaultInterfaceUpdate( + newNetwork: Network? + ) { + val listener = listener ?: return + val link = Application.connectivity.getLinkProperties(newNetwork ?: return) ?: return + listener.updateDefaultInterface(link.interfaceName, -1) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt new file mode 100644 index 0000000..8e37553 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt @@ -0,0 +1,134 @@ +package io.nekohasekai.sfa.bg + +import android.net.DnsResolver +import android.os.Build +import android.os.CancellationSignal +import android.system.ErrnoException +import androidx.annotation.RequiresApi +import io.nekohasekai.libbox.ExchangeContext +import io.nekohasekai.libbox.LocalDNSTransport +import io.nekohasekai.sfa.ktx.tryResumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.UnknownHostException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +object LocalResolver : LocalDNSTransport { + + private const val RCODE_NXDOMAIN = 3 + + override fun raw(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun exchange(ctx: ExchangeContext, message: ByteArray) { + return runBlocking { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + ctx.onCancel(signal::cancel) + val callback = object : DnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { + if (rcode == 0) { + ctx.rawSuccess(answer) + } else { + ctx.errorCode(rcode) + } + continuation.resume(Unit) + } + + override fun onError(error: DnsResolver.DnsException) { + when (val cause = error.cause) { + is ErrnoException -> { + ctx.errnoCode(cause.errno) + continuation.resume(Unit) + return + } + } + continuation.tryResumeWithException(error) + } + } + DnsResolver.getInstance().rawQuery( + DefaultNetworkMonitor.defaultNetwork, + message, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } + } + } + + override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return runBlocking { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + ctx.onCancel(signal::cancel) + val callback = object : DnsResolver.Callback> { + @Suppress("ThrowableNotThrown") + override fun onAnswer(answer: Collection, rcode: Int) { + if (rcode == 0) { + ctx.success((answer as Collection).mapNotNull { it?.hostAddress } + .joinToString("\n")) + } else { + ctx.errorCode(rcode) + } + continuation.resume(Unit) + } + + override fun onError(error: DnsResolver.DnsException) { + when (val cause = error.cause) { + is ErrnoException -> { + ctx.errnoCode(cause.errno) + continuation.resume(Unit) + return + } + } + continuation.tryResumeWithException(error) + } + } + val type = when { + network.endsWith("4") -> DnsResolver.TYPE_A + network.endsWith("6") -> DnsResolver.TYPE_AAAA + else -> null + } + if (type != null) { + DnsResolver.getInstance().query( + DefaultNetworkMonitor.defaultNetwork, + domain, + type, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } else { + DnsResolver.getInstance().query( + DefaultNetworkMonitor.defaultNetwork, + domain, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } + } + } + } else { + val underlyingNetwork = + DefaultNetworkMonitor.defaultNetwork ?: error("upstream network not found") + val answer = try { + underlyingNetwork.getAllByName(domain) + } catch (e: UnknownHostException) { + ctx.errorCode(RCODE_NXDOMAIN) + return + } + ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt new file mode 100644 index 0000000..9dff401 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -0,0 +1,144 @@ +package io.nekohasekai.sfa.bg + +import android.content.pm.PackageManager +import android.os.Build +import android.os.Process +import androidx.annotation.RequiresApi +import io.nekohasekai.libbox.InterfaceUpdateListener +import io.nekohasekai.libbox.NetworkInterfaceIterator +import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.libbox.StringIterator +import io.nekohasekai.libbox.TunOptions +import io.nekohasekai.sfa.Application +import java.net.Inet6Address +import java.net.InetSocketAddress +import java.net.InterfaceAddress +import java.net.NetworkInterface +import java.util.Enumeration +import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface + +interface PlatformInterfaceWrapper : PlatformInterface { + + override fun usePlatformAutoDetectInterfaceControl(): Boolean { + return true + } + + override fun autoDetectInterfaceControl(fd: Int) { + } + + override fun openTun(options: TunOptions): Int { + error("invalid argument") + } + + override fun useProcFS(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun findConnectionOwner( + ipProtocol: Int, + sourceAddress: String, + sourcePort: Int, + destinationAddress: String, + destinationPort: Int + ): Int { + val uid = Application.connectivity.getConnectionOwnerUid( + ipProtocol, + InetSocketAddress(sourceAddress, sourcePort), + InetSocketAddress(destinationAddress, destinationPort) + ) + if (uid == Process.INVALID_UID) error("android: connection owner not found") + return uid + } + + override fun packageNameByUid(uid: Int): String { + val packages = Application.packageManager.getPackagesForUid(uid) + if (packages.isNullOrEmpty()) error("android: package not found") + return packages[0] + } + + @Suppress("DEPRECATION") + override fun uidByPackageName(packageName: String): Int { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageUid( + packageName, PackageManager.PackageInfoFlags.of(0) + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Application.packageManager.getPackageUid(packageName, 0) + } else { + Application.packageManager.getApplicationInfo(packageName, 0).uid + } + } catch (e: PackageManager.NameNotFoundException) { + error("android: package not found") + } + } + + override fun usePlatformDefaultInterfaceMonitor(): Boolean { + return true + } + + override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + DefaultNetworkMonitor.setListener(listener) + } + + override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { + DefaultNetworkMonitor.setListener(null) + } + + override fun usePlatformInterfaceGetter(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + } + + override fun getInterfaces(): NetworkInterfaceIterator { + return InterfaceArray(NetworkInterface.getNetworkInterfaces()) + } + + override fun underNetworkExtension(): Boolean { + return false + } + + private class InterfaceArray(private val iterator: Enumeration) : + NetworkInterfaceIterator { + + override fun hasNext(): Boolean { + return iterator.hasMoreElements() + } + + override fun next(): LibboxNetworkInterface { + val element = iterator.nextElement() + return LibboxNetworkInterface().apply { + name = element.name + index = element.index + runCatching { + mtu = element.mtu + } + addresses = + StringArray( + element.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() } + .iterator() + ) + } + } + + private fun InterfaceAddress.toPrefix(): String { + return if (address is Inet6Address) { + "${Inet6Address.getByAddress(address.address).hostAddress}/${networkPrefixLength}" + } else { + "${address.hostAddress}/${networkPrefixLength}" + } + } + } + + private class StringArray(private val iterator: Iterator) : StringIterator { + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): String { + return iterator.next() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt new file mode 100644 index 0000000..b019140 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt @@ -0,0 +1,17 @@ +package io.nekohasekai.sfa.bg + +import android.app.Service +import android.content.Intent + +class ProxyService : Service(), PlatformInterfaceWrapper { + + private val service = BoxService(this, this) + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int) = + service.onStartCommand(intent, flags, startId) + + override fun onBind(intent: Intent) = service.onBind(intent) + override fun onDestroy() = service.onDestroy() + + override fun writeLog(message: String) = service.writeLog(message) +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt new file mode 100644 index 0000000..d0eb951 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt @@ -0,0 +1,59 @@ +package io.nekohasekai.sfa.bg + +import android.os.RemoteCallbackList +import androidx.lifecycle.MutableLiveData +import io.nekohasekai.sfa.aidl.IService +import io.nekohasekai.sfa.aidl.IServiceCallback +import io.nekohasekai.sfa.constant.Status +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ServiceBinder(private val status: MutableLiveData) : IService.Stub() { + private val callbacks = RemoteCallbackList() + private val broadcastLock = Mutex() + + init { + status.observeForever { + broadcast { callback -> + callback.onServiceStatusChanged(it.ordinal) + } + } + } + + fun broadcast(work: (IServiceCallback) -> Unit) { + GlobalScope.launch(Dispatchers.Main) { + broadcastLock.withLock { + val count = callbacks.beginBroadcast() + try { + repeat(count) { + try { + work(callbacks.getBroadcastItem(it)) + } catch (_: Exception) { + } + } + } finally { + callbacks.finishBroadcast() + } + } + } + } + + override fun getStatus(): Int { + return (status.value ?: Status.Stopped).ordinal + } + + override fun registerCallback(callback: IServiceCallback) { + callbacks.register(callback) + } + + override fun unregisterCallback(callback: IServiceCallback?) { + callbacks.unregister(callback) + } + + fun close() { + callbacks.kill() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt new file mode 100644 index 0000000..65c8787 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt @@ -0,0 +1,115 @@ +package io.nekohasekai.sfa.bg + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.RemoteException +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import io.nekohasekai.sfa.aidl.IService +import io.nekohasekai.sfa.aidl.IServiceCallback +import io.nekohasekai.sfa.constant.Action +import io.nekohasekai.sfa.constant.Alert +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +class ServiceConnection( + private val context: Context, + callback: Callback, + private val register: Boolean = true, +) : ServiceConnection { + + companion object { + private const val TAG = "ServiceConnection" + } + + private val callback = ServiceCallback(callback) + private var service: IService? = null + + val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped + + fun connect() { + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) + } + } + context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) + Log.d(TAG, "request connect") + } + + fun disconnect() { + try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } + Log.d(TAG, "request disconnect") + } + + fun reconnect() { + try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } + val intent = runBlocking { + withContext(Dispatchers.IO) { + Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) + } + } + context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) + Log.d(TAG, "request reconnect") + } + + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + val service = IService.Stub.asInterface(binder) + this.service = service + try { + if (register) service.registerCallback(callback) + callback.onServiceStatusChanged(service.status) + } catch (e: RemoteException) { + Log.e(TAG, "initialize service connection", e) + } + Log.d(TAG, "service connected") + } + + override fun onServiceDisconnected(name: ComponentName?) { + try { + service?.unregisterCallback(callback) + } catch (e: RemoteException) { + Log.e(TAG, "cleanup service connection", e) + } + Log.d(TAG, "service disconnected") + } + + override fun onBindingDied(name: ComponentName?) { + reconnect() + Log.d(TAG, "service dead") + } + + interface Callback { + fun onServiceStatusChanged(status: Status) + fun onServiceAlert(type: Alert, message: String?) {} + fun onServiceWriteLog(message: String?) {} + fun onServiceResetLogs(messages: MutableList) {} + } + + class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() { + override fun onServiceStatusChanged(status: Int) { + callback.onServiceStatusChanged(Status.values()[status]) + } + + override fun onServiceAlert(type: Int, message: String?) { + callback.onServiceAlert(Alert.values()[type], message) + } + + override fun onServiceWriteLog(message: String?) = callback.onServiceWriteLog(message) + + override fun onServiceResetLogs(messages: MutableList) = + callback.onServiceResetLogs(messages) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt new file mode 100644 index 0000000..781b5a0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt @@ -0,0 +1,80 @@ +package io.nekohasekai.sfa.bg + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.Action +import io.nekohasekai.sfa.ui.MainActivity + +class ServiceNotification(private val service: Service) { + companion object { + private const val notificationId = 1 + private const val notificationChannel = "service" + private val flags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + + fun checkPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return true + } + if (Application.notification.areNotificationsEnabled()) { + return true + } + return false + } + } + + + private val notification by lazy { + NotificationCompat.Builder(service, notificationChannel).setWhen(0) + .setContentTitle("sing-box") + .setContentText("service started").setOnlyAlertOnce(true) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent( + PendingIntent.getActivity( + service, + 0, + Intent( + service, + MainActivity::class.java + ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), + flags + ) + ) + .setPriority(NotificationCompat.PRIORITY_LOW).apply { + addAction( + NotificationCompat.Action.Builder( + 0, service.getText(R.string.stop), PendingIntent.getBroadcast( + service, + 0, + Intent(Action.SERVICE_CLOSE).setPackage(service.packageName), + flags + ) + ).build() + ) + } + } + + fun show() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Application.notification.createNotificationChannel( + NotificationChannel( + notificationChannel, "sing-box service", NotificationManager.IMPORTANCE_LOW + ) + ) + } + service.startForeground(notificationId, notification.build()) + } + + fun close() { + ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt new file mode 100644 index 0000000..0b9ff55 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.bg + +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import io.nekohasekai.sfa.constant.Status + +@RequiresApi(24) +class TileService : TileService(), ServiceConnection.Callback { + + private val connection = ServiceConnection(this, this) + + override fun onServiceStatusChanged(status: Status) { + qsTile?.apply { + state = when (status) { + Status.Started -> Tile.STATE_ACTIVE + Status.Stopped -> Tile.STATE_INACTIVE + else -> Tile.STATE_UNAVAILABLE + } + updateTile() + } + } + + override fun onStartListening() { + super.onStartListening() + connection.connect() + } + + override fun onStopListening() { + connection.disconnect() + super.onStopListening() + } + + override fun onClick() { + when (connection.status) { + Status.Stopped -> { + BoxService.start() + } + + Status.Started -> { + BoxService.stop() + } + + else -> {} + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt b/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt new file mode 100644 index 0000000..efe2ad9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt @@ -0,0 +1,92 @@ +package io.nekohasekai.sfa.bg + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.database.Profiles +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.utils.HTTPClient +import java.io.File +import java.util.Date +import java.util.concurrent.TimeUnit + +class UpdateProfileWork { + + companion object { + private const val WORK_NAME = "UpdateProfile" + + suspend fun reconfigureUpdater() { + runCatching { + reconfigureUpdater0() + }.onFailure { + Log.e("UpdateProfileWork", "reconfigureUpdater", it) + } + } + + private suspend fun reconfigureUpdater0() { + WorkManager.getInstance(Application.application).cancelUniqueWork(WORK_NAME) + + val remoteProfiles = Profiles.list() + .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } + if (remoteProfiles.isEmpty()) return + + var minDelay = + remoteProfiles.minByOrNull { it.typed.autoUpdateInterval }!!.typed.autoUpdateInterval.toLong() + val now = System.currentTimeMillis() / 1000L + val minInitDelay = + remoteProfiles.minOf { now - (it.typed.lastUpdated.time / 1000L) - (minDelay * 60) } + if (minDelay < 15) minDelay = 15 + + WorkManager.getInstance(Application.application).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequest.Builder(UpdateTask::class.java, minDelay, TimeUnit.MINUTES) + .apply { + if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS) + setBackoffCriteria(BackoffPolicy.LINEAR, 15, TimeUnit.MINUTES) + } + .build() + ) + } + + } + + class UpdateTask( + appContext: Context, params: WorkerParameters + ) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val remoteProfiles = Profiles.list() + .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } + if (remoteProfiles.isEmpty()) return Result.success() + val httpClient = HTTPClient() + var success = true + for (profile in remoteProfiles) { + try { + val content = httpClient.getString(profile.typed.remoteURL) + Libbox.checkConfig(content) + File(profile.typed.path).writeText(content) + profile.typed.lastUpdated = Date() + Profiles.update(profile) + } catch (e: Exception) { + Log.e("UpdateProfileWork", "error when updating profile ${profile.name}", e) + success = false + } + } + return if (success) { + Result.success() + } else { + Result.retry() + } + } + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt new file mode 100644 index 0000000..1cf2ba1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -0,0 +1,119 @@ +package io.nekohasekai.sfa.bg + +import android.content.Intent +import android.net.ProxyInfo +import android.net.VpnService +import android.os.Build +import io.nekohasekai.libbox.TunOptions + +class VPNService : VpnService(), PlatformInterfaceWrapper { + + companion object { + private const val TAG = "VPNService" + } + + private val service = BoxService(this, this) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = + service.onStartCommand(intent, flags, startId) + + override fun onBind(intent: Intent) = service.onBind(intent) + override fun onDestroy() { + service.onDestroy() + } + + override fun onRevoke() { + service.onRevoke() + } + + override fun autoDetectInterfaceControl(fd: Int) { + protect(fd) + } + + override fun openTun(options: TunOptions): Int { + if (prepare(this) != null) error("android: missing vpn permission") + + val builder = Builder() + .setSession("sing-box") + .setMtu(options.mtu) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setMetered(false) + } + + val inet4Address = options.inet4Address + if (inet4Address.hasNext()) { + while (inet4Address.hasNext()) { + val address = inet4Address.next() + builder.addAddress(address.address, address.prefix) + } + } + + val inet6Address = options.inet6Address + if (inet6Address.hasNext()) { + while (inet6Address.hasNext()) { + val address = inet6Address.next() + builder.addAddress(address.address, address.prefix) + } + } + + if (options.autoRoute) { + builder.addDnsServer(options.dnsServerAddress) + + val inet4RouteAddress = options.inet4RouteAddress + if (inet4RouteAddress.hasNext()) { + while (inet4RouteAddress.hasNext()) { + val address = inet4RouteAddress.next() + builder.addRoute(address.address, address.prefix) + } + } else { + builder.addRoute("0.0.0.0", 0) + } + + val inet6RouteAddress = options.inet6RouteAddress + if (inet6RouteAddress.hasNext()) { + while (inet6RouteAddress.hasNext()) { + val address = inet6RouteAddress.next() + builder.addRoute(address.address, address.prefix) + } + } else { + builder.addRoute("::", 0) + } + + val includePackage = options.includePackage + if (includePackage.hasNext()) { + while (includePackage.hasNext()) { + builder.addAllowedApplication(includePackage.next()) + } + } + + val excludePackage = options.excludePackage + if (excludePackage.hasNext()) { + while (excludePackage.hasNext()) { + builder.addDisallowedApplication(excludePackage.next()) + } + } + } + + if (options.isHTTPProxyEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setHttpProxy( + ProxyInfo.buildDirectProxy( + options.httpProxyServer, + options.httpProxyServerPort + ) + ) + } else { + error("android: tun.platform.http_proxy requires android 10 or higher") + } + } + + val pfd = + builder.establish() ?: error("android: the application is not prepared or is revoked") + service.fileDescriptor = pfd + return pfd.fd + } + + override fun writeLog(message: String) = service.writeLog(message) + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt new file mode 100644 index 0000000..5a65152 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt @@ -0,0 +1,6 @@ +package io.nekohasekai.sfa.constant + +object Action { + const val SERVICE = "io.nekohasekai.sfa.SERVICE" + const val SERVICE_CLOSE = "io.nekohasekai.sfa.SERVICE_CLOSE" +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt new file mode 100644 index 0000000..2c2b439 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt @@ -0,0 +1,10 @@ +package io.nekohasekai.sfa.constant + +enum class Alert { + RequestVPNPermission, + RequestNotificationPermission, + EmptyConfiguration, + StartCommandServer, + CreateService, + StartService +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt b/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt new file mode 100644 index 0000000..750431e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt @@ -0,0 +1,11 @@ +package io.nekohasekai.sfa.constant + +enum class EnabledType(val boolValue: Boolean) { + Enabled(true), Disabled(false); + + companion object { + fun from(value: Boolean): EnabledType { + return if (value) Enabled else Disabled + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt new file mode 100644 index 0000000..c731b61 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt @@ -0,0 +1,6 @@ +package io.nekohasekai.sfa.constant + +object Path { + const val SETTINGS_DATABASE_PATH = "settings.db" + const val PROFILES_DATABASE_PATH = "profiles.db" +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt b/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt new file mode 100644 index 0000000..1bb0ad9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt @@ -0,0 +1,6 @@ +package io.nekohasekai.sfa.constant + +object ServiceMode { + const val NORMAL = "normal" + const val VPN = "vpn" +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt new file mode 100644 index 0000000..1622dc5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.constant + +object SettingsKey { + + const val SELECTED_PROFILE = "selected_profile" + const val SERVICE_MODE = "service_mode" + const val ANALYTICS_ALLOWED = "analytics_allowed" + const val CHECK_UPDATE_ENABLED = "check_update_enabled" + + // cache + + const val STARTED_BY_USER = "started_by_user" + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt new file mode 100644 index 0000000..740637f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt @@ -0,0 +1,8 @@ +package io.nekohasekai.sfa.constant + +enum class Status { + Stopped, + Starting, + Started, + Stopping, +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt b/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt new file mode 100644 index 0000000..95deec2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sfa.database + +import android.os.Parcelable +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.TypeConverters +import androidx.room.Update +import kotlinx.parcelize.Parcelize + +@Entity( + tableName = "profiles", +) +@TypeConverters(TypedProfile.Convertor::class) +@Parcelize +class Profile( + @PrimaryKey(autoGenerate = true) var id: Long = 0L, + var userOrder: Long = 0L, + var name: String = "", + var typed: TypedProfile = TypedProfile() +) : Parcelable { + + @androidx.room.Dao + interface Dao { + + @Insert + fun insert(profile: Profile): Long + + @Update + fun update(profile: Profile): Int + + @Update + fun update(profile: List): Int + + @Delete + fun delete(profile: Profile): Int + + @Delete + fun delete(profile: List): Int + + @Query("SELECT * FROM profiles WHERE id = :profileId") + fun get(profileId: Long): Profile? + + @Query("select * from profiles order by userOrder asc") + fun list(): List + + @Query("DELETE FROM profiles") + fun clear() + + @Query("SELECT MAX(userOrder) + 1 FROM profiles") + fun nextOrder(): Long? + + } + +} + diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt new file mode 100644 index 0000000..24cbff0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt @@ -0,0 +1,13 @@ +package io.nekohasekai.sfa.database + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [Profile::class], version = 1 +) +abstract class ProfileDatabase : RoomDatabase() { + + abstract fun profileDao(): Profile.Dao + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Profiles.kt b/app/src/main/java/io/nekohasekai/sfa/database/Profiles.kt new file mode 100644 index 0000000..641d4d2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/Profiles.kt @@ -0,0 +1,53 @@ +package io.nekohasekai.sfa.database + +import androidx.room.Room +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.constant.Path +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Suppress("RedundantSuspendModifier") +object Profiles { + + private val instance by lazy { + Application.application.getDatabasePath(Path.PROFILES_DATABASE_PATH).parentFile?.mkdirs() + Room.databaseBuilder( + Application.application, ProfileDatabase::class.java, Path.PROFILES_DATABASE_PATH + ).fallbackToDestructiveMigration().setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + + suspend fun nextOrder(): Long { + return instance.profileDao().nextOrder() ?: 0 + } + + suspend fun get(id: Long): Profile? { + return instance.profileDao().get(id) + } + + suspend fun create(profile: Profile): Profile { + profile.id = instance.profileDao().insert(profile) + return profile + } + + suspend fun update(profile: Profile): Int { + return instance.profileDao().update(profile) + } + + suspend fun update(profiles: List): Int { + return instance.profileDao().update(profiles) + } + + suspend fun delete(profile: Profile): Int { + return instance.profileDao().delete(profile) + } + + suspend fun delete(profiles: List): Int { + return instance.profileDao().delete(profiles) + } + + suspend fun list(): List { + return instance.profileDao().list() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt new file mode 100644 index 0000000..ee012f5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -0,0 +1,83 @@ +package io.nekohasekai.sfa.database + +import androidx.room.Room +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.bg.ProxyService +import io.nekohasekai.sfa.bg.VPNService +import io.nekohasekai.sfa.constant.Path +import io.nekohasekai.sfa.constant.ServiceMode +import io.nekohasekai.sfa.constant.SettingsKey +import io.nekohasekai.sfa.database.preference.KeyValueDatabase +import io.nekohasekai.sfa.database.preference.RoomPreferenceDataStore +import io.nekohasekai.sfa.ktx.boolean +import io.nekohasekai.sfa.ktx.int +import io.nekohasekai.sfa.ktx.long +import io.nekohasekai.sfa.ktx.string +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.io.File + +object Settings { + + private val instance by lazy { + Application.application.getDatabasePath(Path.SETTINGS_DATABASE_PATH).parentFile?.mkdirs() + Room.databaseBuilder( + Application.application, + KeyValueDatabase::class.java, + Path.SETTINGS_DATABASE_PATH + ).allowMainThreadQueries() + .fallbackToDestructiveMigration() + .setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + val dataStore = RoomPreferenceDataStore(instance.keyValuePairDao()) + var selectedProfile by dataStore.long(SettingsKey.SELECTED_PROFILE) { -1L } + var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } + var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) + + const val ANALYSIS_UNKNOWN = -1 + const val ANALYSIS_ALLOWED = 0 + const val ANALYSIS_DISALLOWED = 1 + + var analyticsAllowed by dataStore.int(SettingsKey.ANALYTICS_ALLOWED) { ANALYSIS_UNKNOWN } + var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true } + + fun serviceClass(): Class<*> { + return when (serviceMode) { + ServiceMode.VPN -> VPNService::class.java + else -> ProxyService::class.java + } + } + + suspend fun rebuildServiceMode(): Boolean { + var newMode = ServiceMode.NORMAL + try { + if (needVPNService()) { + newMode = ServiceMode.VPN + } + } catch (_: Exception) { + } + if (serviceMode == newMode) { + return false + } + serviceMode = newMode + return true + } + + private suspend fun needVPNService(): Boolean { + val selectedProfileId = selectedProfile + if (selectedProfileId == -1L) return false + val profile = Profiles.get(selectedProfile) ?: return false + val content = JSONObject(File(profile.typed.path).readText()) + val inbounds = content.getJSONArray("inbounds") + for (index in 0 until inbounds.length()) { + val inbound = inbounds.getJSONObject(index) + if (inbound.getString("type") == "tun") { + return true + } + } + return false + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt b/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt new file mode 100644 index 0000000..8481238 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt @@ -0,0 +1,81 @@ +package io.nekohasekai.sfa.database + +import android.os.Parcel +import android.os.Parcelable +import androidx.room.TypeConverter +import io.nekohasekai.sfa.ktx.marshall +import io.nekohasekai.sfa.ktx.unmarshall +import java.util.Date + +class TypedProfile() : Parcelable { + + enum class Type { + Local, Remote; + + companion object { + fun valueOf(value: Int): Type { + for (it in values()) { + if (it.ordinal == value) { + return it + } + } + return Local + } + } + } + + var path = "" + var type = Type.Local + var remoteURL: String = "" + var lastUpdated: Date = Date(0) + var autoUpdate: Boolean = false + var autoUpdateInterval = 60 + + constructor(reader: Parcel) : this() { + val version = reader.readInt() + path = reader.readString() ?: "" + type = Type.valueOf(reader.readInt()) + remoteURL = reader.readString() ?: "" + autoUpdate = reader.readInt() == 1 + lastUpdated = Date(reader.readLong()) + if (version >= 1) { + autoUpdateInterval = reader.readInt() + } + } + + override fun writeToParcel(writer: Parcel, flags: Int) { + writer.writeInt(1) + writer.writeString(path) + writer.writeInt(type.ordinal) + writer.writeString(remoteURL) + writer.writeInt(if (autoUpdate) 1 else 0) + writer.writeLong(lastUpdated.time) + writer.writeInt(autoUpdateInterval) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): TypedProfile { + return TypedProfile(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + class Convertor { + + @TypeConverter + fun marshall(profile: TypedProfile) = profile.marshall() + + @TypeConverter + fun unmarshall(content: ByteArray) = + content.unmarshall(::TypedProfile) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt new file mode 100644 index 0000000..d94ad92 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt @@ -0,0 +1,13 @@ +package io.nekohasekai.sfa.database.preference + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [KeyValueEntity::class], version = 1 +) +abstract class KeyValueDatabase : RoomDatabase() { + + abstract fun keyValuePairDao(): KeyValueEntity.Dao + +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt new file mode 100644 index 0000000..2389b35 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt @@ -0,0 +1,156 @@ +package io.nekohasekai.sfa.database.preference + +import android.os.Parcel +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +@Entity +class KeyValueEntity() : Parcelable { + companion object { + const val TYPE_UNINITIALIZED = 0 + const val TYPE_BOOLEAN = 1 + const val TYPE_FLOAT = 2 + const val TYPE_LONG = 3 + const val TYPE_STRING = 4 + const val TYPE_STRING_SET = 5 + + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): KeyValueEntity { + return KeyValueEntity(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM KeyValueEntity") + fun all(): List + + @Query("SELECT * FROM KeyValueEntity WHERE `key` = :key") + operator fun get(key: String): KeyValueEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun put(value: KeyValueEntity): Long + + @Query("DELETE FROM KeyValueEntity WHERE `key` = :key") + fun delete(key: String): Int + + @Query("DELETE FROM KeyValueEntity") + fun reset(): Int + + @Insert + fun insert(list: List) + } + + @PrimaryKey + var key: String = "" + var valueType: Int = TYPE_UNINITIALIZED + var value: ByteArray = ByteArray(0) + + val boolean: Boolean? + get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null + val float: Float? + get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null + + val long: Long + get() = ByteBuffer.wrap(value).long + + val string: String? + get() = if (valueType == TYPE_STRING) String(value) else null + val stringSet: Set? + get() = if (valueType == TYPE_STRING_SET) { + val buffer = ByteBuffer.wrap(value) + val result = HashSet() + while (buffer.hasRemaining()) { + val chArr = ByteArray(buffer.int) + buffer.get(chArr) + result.add(String(chArr)) + } + result + } else null + + @Ignore + constructor(key: String) : this() { + this.key = key + } + + // putting null requires using DataStore + fun put(value: Boolean): KeyValueEntity { + valueType = TYPE_BOOLEAN + this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array() + return this + } + + fun put(value: Float): KeyValueEntity { + valueType = TYPE_FLOAT + this.value = ByteBuffer.allocate(4).putFloat(value).array() + return this + } + + fun put(value: Long): KeyValueEntity { + valueType = TYPE_LONG + this.value = ByteBuffer.allocate(8).putLong(value).array() + return this + } + + fun put(value: String): KeyValueEntity { + valueType = TYPE_STRING + this.value = value.toByteArray() + return this + } + + fun put(value: Set): KeyValueEntity { + valueType = TYPE_STRING_SET + val stream = ByteArrayOutputStream() + val intBuffer = ByteBuffer.allocate(4) + for (v in value) { + intBuffer.rewind() + stream.write(intBuffer.putInt(v.length).array()) + stream.write(v.toByteArray()) + } + this.value = stream.toByteArray() + return this + } + + @Suppress("IMPLICIT_CAST_TO_ANY") + override fun toString(): String { + return when (valueType) { + TYPE_BOOLEAN -> boolean + TYPE_FLOAT -> float + TYPE_LONG -> long + TYPE_STRING -> string + TYPE_STRING_SET -> stringSet + else -> null + }?.toString() ?: "null" + } + + constructor(parcel: Parcel) : this() { + key = parcel.readString()!! + valueType = parcel.readInt() + value = parcel.createByteArray()!! + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(key) + parcel.writeInt(valueType) + parcel.writeByteArray(value) + } + + override fun describeContents(): Int { + return 0 + } + +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt new file mode 100644 index 0000000..ac5c7b8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt @@ -0,0 +1,7 @@ +package io.nekohasekai.sfa.database.preference + +import androidx.preference.PreferenceDataStore + +interface OnPreferenceDataStoreChangeListener { + fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt new file mode 100644 index 0000000..ac10693 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt @@ -0,0 +1,90 @@ +package io.nekohasekai.sfa.database.preference + +import androidx.preference.PreferenceDataStore + +@Suppress("MemberVisibilityCanBePrivate", "unused") +open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : + PreferenceDataStore() { + + fun getBoolean(key: String) = kvPairDao[key]?.boolean + fun getFloat(key: String) = kvPairDao[key]?.float + fun getInt(key: String) = kvPairDao[key]?.long?.toInt() + fun getLong(key: String) = kvPairDao[key]?.long + fun getString(key: String) = kvPairDao[key]?.string + fun getStringSet(key: String) = kvPairDao[key]?.stringSet + fun reset() = kvPairDao.reset() + + override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue + override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue + override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue + override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue + override fun getString(key: String, defValue: String?) = getString(key) ?: defValue + override fun getStringSet(key: String, defValue: MutableSet?) = + getStringSet(key) ?: defValue + + fun putBoolean(key: String, value: Boolean?) = + if (value == null) remove(key) else putBoolean(key, value) + + fun putFloat(key: String, value: Float?) = + if (value == null) remove(key) else putFloat(key, value) + + fun putInt(key: String, value: Int?) = + if (value == null) remove(key) else putLong(key, value.toLong()) + + fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value) + override fun putBoolean(key: String, value: Boolean) { + kvPairDao.put(KeyValueEntity(key).put(value)) + fireChangeListener(key) + } + + override fun putFloat(key: String, value: Float) { + kvPairDao.put(KeyValueEntity(key).put(value)) + fireChangeListener(key) + } + + override fun putInt(key: String, value: Int) { + kvPairDao.put(KeyValueEntity(key).put(value.toLong())) + fireChangeListener(key) + } + + override fun putLong(key: String, value: Long) { + kvPairDao.put(KeyValueEntity(key).put(value)) + fireChangeListener(key) + } + + override fun putString(key: String, value: String?) = if (value == null) remove(key) else { + kvPairDao.put(KeyValueEntity(key).put(value)) + fireChangeListener(key) + } + + override fun putStringSet(key: String, values: MutableSet?) = + if (values == null) remove(key) else { + kvPairDao.put(KeyValueEntity(key).put(values)) + fireChangeListener(key) + } + + fun remove(key: String) { + kvPairDao.delete(key) + fireChangeListener(key) + } + + private val listeners = HashSet() + private fun fireChangeListener(key: String) { + val listeners = synchronized(listeners) { + listeners.toList() + } + listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } + } + + fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) { + synchronized(listeners) { + listeners.remove(listener) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt new file mode 100644 index 0000000..0c4786d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt @@ -0,0 +1,26 @@ +package io.nekohasekai.sfa.ktx + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import com.google.android.material.elevation.SurfaceColors + +fun Context.launchCustomTab(link: String) { + val color = SurfaceColors.SURFACE_2.getColor(this) + CustomTabsIntent.Builder().apply { + setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM) + setColorSchemeParams( + CustomTabsIntent.COLOR_SCHEME_LIGHT, + CustomTabColorSchemeParams.Builder().apply { + setToolbarColor(color) + }.build() + ) + setColorSchemeParams( + CustomTabsIntent.COLOR_SCHEME_DARK, + CustomTabColorSchemeParams.Builder().apply { + setToolbarColor(color) + }.build() + ) + }.build().launchUrl(this, Uri.parse(link)) +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt new file mode 100644 index 0000000..6d7521b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt @@ -0,0 +1,17 @@ +package io.nekohasekai.sfa.ktx + +import android.content.Context +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt + + +@ColorInt +fun Context.getAttrColor( + @AttrRes attrColor: Int, + typedValue: TypedValue = TypedValue(), + resolveRefs: Boolean = true +): Int { + theme.resolveAttribute(attrColor, typedValue, resolveRefs) + return typedValue.data +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt new file mode 100644 index 0000000..aad9f83 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt @@ -0,0 +1,18 @@ +package io.nekohasekai.sfa.ktx + +import kotlin.coroutines.Continuation + + +fun Continuation.tryResume(value: T) { + try { + resumeWith(Result.success(value)) + } catch (ignored: IllegalStateException) { + } +} + +fun Continuation.tryResumeWithException(exception: Throwable) { + try { + resumeWith(Result.failure(exception)) + } catch (ignored: IllegalStateException) { + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt new file mode 100644 index 0000000..02a3bc1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt @@ -0,0 +1,24 @@ +package io.nekohasekai.sfa.ktx + +import android.content.Context +import androidx.annotation.StringRes +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sfa.R + +fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder { + return MaterialAlertDialogBuilder(this) + .setTitle(R.string.error_title) + .setMessage(messageId) + .setPositiveButton(resources.getString(android.R.string.ok), null) +} + +fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { + return MaterialAlertDialogBuilder(this) + .setTitle(R.string.error_title) + .setMessage(message) + .setPositiveButton(resources.getString(android.R.string.ok), null) +} + +fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder { + return errorDialogBuilder(exception.localizedMessage ?: exception.toString()) +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt new file mode 100644 index 0000000..4116af7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt @@ -0,0 +1,51 @@ +package io.nekohasekai.sfa.ktx + +import androidx.annotation.ArrayRes +import androidx.core.widget.addTextChangedListener +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.textfield.TextInputLayout +import io.nekohasekai.sfa.R + +var TextInputLayout.text: String + get() = editText?.text?.toString() ?: "" + set(value) { + editText?.setText(value) + } + +var TextInputLayout.error: String + get() = editText?.error?.toString() ?: "" + set(value) { + editText?.error = value + } + + +fun TextInputLayout.setSimpleItems(@ArrayRes redId: Int) { + (editText as? MaterialAutoCompleteTextView)?.setSimpleItems(redId) +} + +fun TextInputLayout.removeErrorIfNotEmpty() { + addOnEditTextAttachedListener { + editText?.addTextChangedListener { + if (text.isNotBlank()) { + error = null + } + } + } +} + +fun TextInputLayout.showErrorIfEmpty(): Boolean { + if (text.isBlank()) { + error = context.getString(R.string.profile_input_required) + return true + } + return false +} + + +fun TextInputLayout.addTextChangedListener(listener: (String) -> Unit) { + addOnEditTextAttachedListener { + editText?.addTextChangedListener { + listener(it?.toString() ?: "") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt new file mode 100644 index 0000000..6b91850 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt @@ -0,0 +1,21 @@ +package io.nekohasekai.sfa.ktx + +import android.content.ActivityNotFoundException +import androidx.activity.result.ActivityResultLauncher +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.ui.shared.AbstractActivity + +fun AbstractActivity.startFilesForResult( + launcher: ActivityResultLauncher, input: String +) { + try { + return launcher.launch(input) + } catch (_: ActivityNotFoundException) { + } catch (_: SecurityException) { + } + val builder = MaterialAlertDialogBuilder(this) + builder.setPositiveButton(resources.getString(android.R.string.ok), null) + builder.setMessage(R.string.file_manager_missing) + builder.show() +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt new file mode 100644 index 0000000..e6806bb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt @@ -0,0 +1,66 @@ +package io.nekohasekai.sfa.ktx + +import androidx.preference.PreferenceDataStore +import kotlin.reflect.KProperty + +fun PreferenceDataStore.string( + name: String, + defaultValue: () -> String = { "" }, +) = PreferenceProxy(name, defaultValue, ::getString, ::putString) + +fun PreferenceDataStore.stringNotBlack( + name: String, + defaultValue: () -> String = { "" }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, default)?.takeIf { it.isNotBlank() } ?: default +}, { key, value -> + putString(key, value.takeIf { it.isNotBlank() } ?: defaultValue()) +}) + +fun PreferenceDataStore.boolean( + name: String, + defaultValue: () -> Boolean = { false }, +) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) + +fun PreferenceDataStore.int( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) + +fun PreferenceDataStore.stringToInt( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, "$default")?.toIntOrNull() ?: default +}, { key, value -> putString(key, "$value") }) + +fun PreferenceDataStore.stringToIntIfExists( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, "$default")?.toIntOrNull() ?: default +}, { key, value -> putString(key, value.takeIf { it > 0 }?.toString() ?: "") }) + +fun PreferenceDataStore.long( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) + +fun PreferenceDataStore.stringToLong( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, "$default")?.toLongOrNull() ?: default +}, { key, value -> putString(key, "$value") }) + +class PreferenceProxy( + val name: String, + val defaultValue: () -> T, + val getter: (String, T) -> T?, + val setter: (String, value: T) -> Unit, +) { + + operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value) + operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!! + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt new file mode 100644 index 0000000..f8c672f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt @@ -0,0 +1,21 @@ +package io.nekohasekai.sfa.ktx + +import android.os.Parcel +import android.os.Parcelable + +fun Parcelable.marshall(): ByteArray { + val parcel = Parcel.obtain() + writeToParcel(parcel, 0) + val content = parcel.marshall() + parcel.recycle() + return content +} + +fun ByteArray.unmarshall(constructor: (Parcel) -> T): T { + val parcel = Parcel.obtain() + parcel.unmarshall(this, 0, size) + parcel.setDataPosition(0) // This is extremely important! + val result = constructor(parcel) + parcel.recycle() + return result +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt new file mode 100644 index 0000000..64fd11a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt @@ -0,0 +1,331 @@ +package io.nekohasekai.sfa.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Bundle +import android.text.TextUtils +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.microsoft.appcenter.AppCenter +import com.microsoft.appcenter.analytics.Analytics +import com.microsoft.appcenter.crashes.Crashes +import com.microsoft.appcenter.distribute.Distribute +import com.microsoft.appcenter.distribute.DistributeListener +import com.microsoft.appcenter.distribute.ReleaseDetails +import com.microsoft.appcenter.distribute.UpdateAction +import com.microsoft.appcenter.utils.AppNameHelper +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.bg.ServiceNotification +import io.nekohasekai.sfa.constant.Alert +import io.nekohasekai.sfa.constant.ServiceMode +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.ActivityMainBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +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.util.LinkedList + +class MainActivity : AbstractActivity(), ServiceConnection.Callback, DistributeListener { + + companion object { + private const val TAG = "MyActivity" + } + + private lateinit var binding: ActivityMainBinding + private val connection = ServiceConnection(this, this) + + val logList = LinkedList() + var logCallback: ((Boolean) -> Unit)? = null + val serviceStatus = MutableLiveData(Status.Stopped) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navController = findNavController(R.id.nav_host_fragment_activity_my) + val 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() + startAnalysis() + } + + fun reconnect() { + connection.reconnect() + } + + private fun startAnalysis() { + lifecycleScope.launch(Dispatchers.IO) { + when (Settings.analyticsAllowed) { + Settings.ANALYSIS_UNKNOWN -> { + withContext(Dispatchers.Main) { + showAnalysisDialog() + } + } + + Settings.ANALYSIS_ALLOWED -> { + startAnalysisInternal() + } + } + } + } + + private fun showAnalysisDialog() { + val builder = MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.analytics_title)) + .setMessage(getString(R.string.analytics_message)) + .setPositiveButton(getString(R.string.ok)) { _, _ -> + lifecycleScope.launch(Dispatchers.IO) { + Settings.analyticsAllowed = Settings.ANALYSIS_ALLOWED + startAnalysisInternal() + } + } + .setNegativeButton(getString(R.string.no_thanks)) { _, _ -> + lifecycleScope.launch(Dispatchers.IO) { + Settings.analyticsAllowed = Settings.ANALYSIS_DISALLOWED + } + } + runCatching { builder.show() } + } + + suspend fun startAnalysisInternal() { + if (BuildConfig.APPCENTER_SECRET.isBlank()) { + return + } + Distribute.setListener(this) + runCatching { + AppCenter.start( + application, + BuildConfig.APPCENTER_SECRET, + Analytics::class.java, + Crashes::class.java, + Distribute::class.java, + ) + if (!Settings.checkUpdateEnabled) { + Distribute.disableAutomaticCheckForUpdate() + } + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it).show() + } + } + } + + override fun onReleaseAvailable(activity: Activity, releaseDetails: ReleaseDetails): Boolean { + lifecycleScope.launch(Dispatchers.Main) { + delay(2000L) + runCatching { + onReleaseAvailable0(releaseDetails) + } + } + return true + } + + private fun onReleaseAvailable0(releaseDetails: ReleaseDetails) { + val builder = MaterialAlertDialogBuilder(this) + .setTitle(getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_title)) + var message = if (releaseDetails.isMandatoryUpdate) { + getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_message_mandatory) + } else { + getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_message_optional) + } + message = String.format( + message, + AppNameHelper.getAppName(this), + releaseDetails.shortVersion, + releaseDetails.version + ) + builder.setMessage(message) + builder.setPositiveButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_download) { _, _ -> + startActivity(Intent(Intent.ACTION_VIEW, releaseDetails.downloadUrl)) + } + builder.setCancelable(false) + if (!releaseDetails.isMandatoryUpdate) { + builder.setNegativeButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_postpone) { _, _ -> + Distribute.notifyUpdateAction(UpdateAction.POSTPONE) + } + } + if (!TextUtils.isEmpty(releaseDetails.releaseNotes) && releaseDetails.releaseNotesUrl != null) { + builder.setNeutralButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_view_release_notes) { _, _ -> + startActivity(Intent(Intent.ACTION_VIEW, releaseDetails.releaseNotesUrl)) + } + } + builder.show() + } + + override fun onNoReleaseAvailable(activity: Activity) { + } + + + @SuppressLint("NewApi") + fun startService() { + if (!ServiceNotification.checkPermission()) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + 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 (it) { + startService() + } else { + onServiceAlert(Alert.RequestNotificationPermission, null) + } + } + + 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?) { + val builder = MaterialAlertDialogBuilder(this) + builder.setPositiveButton(resources.getString(android.R.string.ok), null) + when (type) { + Alert.RequestVPNPermission -> { + builder.setMessage(getString(R.string.service_error_missing_permission)) + } + + Alert.RequestNotificationPermission -> { + builder.setMessage(getString(R.string.service_error_missing_notification_permission)) + } + + 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) + + } + } + builder.show() + } + + private var paused = false + override fun onPause() { + super.onPause() + + paused = true + } + + override fun onResume() { + super.onResume() + + paused = false + logCallback?.invoke(true) + } + + override fun onServiceWriteLog(message: String?) { + if (paused) { + if (logList.size > 300) { + logList.removeFirst() + } + } + logList.addLast(message) + if (!paused) { + logCallback?.invoke(false) + } + } + + override fun onServiceResetLogs(messages: MutableList) { + logList.clear() + logList.addAll(messages) + if (!paused) logCallback?.invoke(true) + } + + override fun onDestroy() { + connection.disconnect() + super.onDestroy() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt new file mode 100644 index 0000000..307af12 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt @@ -0,0 +1,67 @@ +package io.nekohasekai.sfa.ui + +import android.app.Activity +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 { + 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() + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..94c02d7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt @@ -0,0 +1,180 @@ +package io.nekohasekai.sfa.ui.main + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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 io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.Profiles +import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding +import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding +import io.nekohasekai.sfa.ui.profile.EditProfileActivity +import io.nekohasekai.sfa.ui.profile.NewProfileActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ConfigurationFragment : Fragment() { + + private var _adapter: Adapter? = null + private var adapter: Adapter + get() = _adapter as Adapter + set(value) { + _adapter = value + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val binding = FragmentConfigurationBinding.inflate(inflater, container, false) + adapter = Adapter(lifecycleScope, binding) + 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) { + } + }).attachToRecyclerView(it) + } + adapter.reload() + binding.fab.setOnClickListener { + startActivity(Intent(requireContext(), NewProfileActivity::class.java)) + } + return binding.root + } + + override fun onResume() { + super.onResume() + _adapter?.reload() + } + + override fun onDestroyView() { + super.onDestroyView() + _adapter = null + } + + class Adapter( + internal val scope: CoroutineScope, + private val parent: FragmentConfigurationBinding + ) : + RecyclerView.Adapter() { + + internal var items: MutableList = mutableListOf() + + internal fun reload() { + scope.launch(Dispatchers.IO) { + items = Profiles.list().toMutableList() + withContext(Dispatchers.Main) { + if (items.isEmpty()) { + parent.statusText.isVisible = true + parent.profileList.isVisible = false + } else if (parent.statusText.isVisible) { + parent.statusText.isVisible = false + parent.profileList.isVisible = true + } + notifyDataSetChanged() + } + } + } + + internal fun move(from: Int, to: Int): Boolean { + val first = items.getOrNull(from) ?: return false + var previousOrder = first.userOrder + val (step, range) = if (from < to) Pair(1, from until to) else Pair( + -1, to + 1 downTo from + ) + val updated = mutableListOf() + for (i in range) { + val next = items.getOrNull(i + step) ?: return false + val order = next.userOrder + next.userOrder = previousOrder + previousOrder = order + updated.add(next) + } + first.userOrder = previousOrder + updated.add(first) + notifyItemMoved(from, to) + GlobalScope.launch(Dispatchers.IO) { + Profiles.update(updated) + } + return true + } + + 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 + 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 { it -> + val popup = PopupMenu(it.context, it) + popup.setForceShowIcon(true) + popup.menuInflater.inflate(R.menu.profile_menu, popup.menu) + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_delete -> { + adapter.items.remove(profile) + adapter.notifyItemRemoved(adapterPosition) + adapter.scope.launch(Dispatchers.IO) { + runCatching { + Profiles.delete(profile) + } + } + true + } + + else -> false + } + } + popup.show() + } + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..1ba5cc9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt @@ -0,0 +1,276 @@ +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 com.google.android.material.divider.MaterialDividerItemDecoration +import go.Seq +import io.nekohasekai.libbox.CommandClient +import io.nekohasekai.libbox.CommandClientHandler +import io.nekohasekai.libbox.CommandClientOptions +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.Profiles +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.databinding.FragmentDashboardBinding +import io.nekohasekai.sfa.databinding.ViewProfileItemBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ui.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class DashboardFragment : Fragment(), CommandClientHandler { + + private val activity: MainActivity? get() = super.getActivity() as MainActivity? + private var _binding: FragmentDashboardBinding? = null + private val binding get() = _binding!! + private var commandClient: CommandClient? = null + + private var _adapter: Adapter? = null + private val adapter get() = _adapter!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = FragmentDashboardBinding.inflate(inflater, container, false) + onCreate() + return binding.root + } + + private fun onCreate() { + val activity = activity ?: 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.statusCard.isVisible = it == Status.Starting || it == Status.Started + when (it) { + Status.Stopped -> { + binding.fab.setImageResource(R.drawable.ic_play_arrow_24) + binding.fab.show() + } + + Status.Starting -> { + binding.fab.hide() + } + + Status.Started -> { + binding.fab.setImageResource(R.drawable.ic_stop_24) + binding.fab.show() + reconnect() + } + + Status.Stopping -> { + binding.fab.hide() + } + + else -> {} + } + } + binding.fab.setOnClickListener { + when (activity.serviceStatus.value) { + Status.Stopped -> { + activity.startService() + } + + Status.Started -> { + BoxService.stop() + } + + else -> {} + } + } + } + + private fun reconnect() { + disconnect() + val options = CommandClientOptions() + options.command = Libbox.CommandStatus + options.statusInterval = 2 * 1000 * 1000 * 1000 + val commandClient = CommandClient(requireContext().filesDir.absolutePath, this, options) + this.commandClient = commandClient + lifecycleScope.launch(Dispatchers.IO) { + for (i in 1..3) { + delay(100) + try { + commandClient.connect() + break + } catch (e: Exception) { + break + } + } + } + } + + private fun disconnect() { + commandClient?.apply { + runCatching { + disconnect() + } + Seq.destroyRef(refnum) + } + commandClient = null + } + + override fun onDestroyView() { + super.onDestroyView() + _adapter = null + _binding = null + disconnect() + } + + override fun connected() { + val binding = _binding ?: return + lifecycleScope.launch(Dispatchers.Main) { + binding.memoryText.text = getString(R.string.loading) + binding.goroutinesText.text = getString(R.string.loading) + } + } + + override fun disconnected(message: String?) { + val binding = _binding ?: return + lifecycleScope.launch(Dispatchers.Main) { + binding.memoryText.text = getString(R.string.loading) + binding.goroutinesText.text = getString(R.string.loading) + } + } + + override fun writeLog(message: String) { + } + + override fun writeStatus(message: StatusMessage) { + val binding = _binding ?: return + lifecycleScope.launch(Dispatchers.Main) { + binding.memoryText.text = Libbox.formatBytes(message.memory) + binding.goroutinesText.text = message.goroutines.toString() + } + } + + class Adapter( + internal val scope: CoroutineScope, + private val parent: FragmentDashboardBinding + ) : + RecyclerView.Adapter() { + + internal var items: MutableList = mutableListOf() + internal var selectedProfileID = -1L + internal var lastSelectedIndex: Int? = null + internal fun reload() { + scope.launch(Dispatchers.IO) { + items = Profiles.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.selectedProfileID = profile.id + adapter.lastSelectedIndex?.let { index -> + adapter.notifyItemChanged(index) + } + adapter.lastSelectedIndex = adapterPosition + adapter.scope.launch(Dispatchers.IO) { + switchProfile(profile) + } + } + } + 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(200) + mainActivity.startService() + return + } + runCatching { + Libbox.clientServiceReload(mainActivity.filesDir.absolutePath) + }.onFailure { + withContext(Dispatchers.Main) { + mainActivity.errorDialogBuilder(it).show() + } + } + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..b15efe1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt @@ -0,0 +1,147 @@ +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.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +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 java.util.LinkedList + +class LogFragment : Fragment() { + private val activity: MainActivity? get() = super.getActivity() as MainActivity? + private var _binding: FragmentLogBinding? = null + private val binding get() = _binding!! + private var logAdapter: LogAdapter? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = FragmentLogBinding.inflate(inflater, container, false) + onCreate() + return binding.root + } + + private fun onCreate() { + val activity = activity ?: return + activity.logCallback = ::updateViews + binding.logView.layoutManager = LinearLayoutManager(requireContext()) + binding.logView.adapter = LogAdapter(activity.logList).also { logAdapter = it } + updateViews(true) + 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 -> { + binding.fab.setImageResource(R.drawable.ic_stop_24) + binding.fab.show() + 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 -> { + activity.startService() + } + + Status.Started -> { + BoxService.stop() + } + + else -> {} + } + } + } + + private fun updateViews(reset: Boolean) { + val activity = activity ?: return + val logAdapter = logAdapter ?: return + if (activity.logList.isEmpty()) { + binding.logView.isVisible = false + binding.statusText.isVisible = true + } else if (!binding.logView.isVisible) { + binding.logView.isVisible = true + binding.statusText.isVisible = false + } + if (reset) { + logAdapter.notifyDataSetChanged() + binding.logView.scrollToPosition(activity.logList.size - 1) + } else { + binding.logView.scrollToPosition(logAdapter.notifyItemInserted()) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + activity?.logCallback = null + logAdapter = null + } + + + class LogAdapter(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 + } + + fun notifyItemInserted(): Int { + if (logList.size > 300) { + logList.removeFirst() + notifyItemRemoved(0) + } + + val position = logList.size - 1 + notifyItemInserted(position) + return position + } + + } + + class LogViewHolder(private val binding: ViewLogTextItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(message: String) { + binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message) + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..0d457c6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.ui.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.microsoft.appcenter.AppCenter +import com.microsoft.appcenter.distribute.Distribute +import io.nekohasekai.libbox.Libbox +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SettingsFragment : Fragment() { + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(inflater, container, false) + onCreate() + return binding.root + } + + private fun onCreate() { + val activity = activity as MainActivity? ?: return + binding.versionText.text = Libbox.version() + binding.clearButton.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + activity.getExternalFilesDir(null)?.deleteRecursively() + reloadSettings() + } + } + lifecycleScope.launch(Dispatchers.IO) { + reloadSettings() + } + binding.appCenterEnabled.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + val allowed = EnabledType.valueOf(it).boolValue + Settings.analyticsAllowed = + if (allowed) Settings.ANALYSIS_ALLOWED else Settings.ANALYSIS_DISALLOWED + withContext(Dispatchers.Main) { + binding.checkUpdateEnabled.isEnabled = allowed + } + if (!allowed) { + AppCenter.setEnabled(false) + } else { + if (!AppCenter.isConfigured()) { + activity.startAnalysisInternal() + } + AppCenter.setEnabled(true) + } + } + } + binding.checkUpdateEnabled.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + val newValue = EnabledType.valueOf(it).boolValue + Settings.checkUpdateEnabled = newValue + if (!newValue) { + Distribute.disableAutomaticCheckForUpdate() + } + } + } + binding.communityButton.setOnClickListener { + it.context.launchCustomTab("https://community.sagernet.org/") + } + binding.documentationButton.setOnClickListener { + it.context.launchCustomTab("http://sing-box.sagernet.org/installation/clients/sfa/") + } + } + + private suspend fun reloadSettings() { + val activity = activity ?: return + val dataSize = Libbox.formatBytes( + (activity.getExternalFilesDir(null) ?: activity.filesDir) + .walkTopDown().filter { it.isFile }.map { it.length() }.sum() + ) + val appCenterEnabled = Settings.analyticsAllowed == Settings.ANALYSIS_ALLOWED + val checkUpdateEnabled = Settings.checkUpdateEnabled + withContext(Dispatchers.Main) { + binding.dataSizeText.text = dataSize + binding.appCenterEnabled.text = EnabledType.from(appCenterEnabled).name + binding.appCenterEnabled.setSimpleItems(R.array.enabled) + binding.checkUpdateEnabled.isEnabled = appCenterEnabled + binding.checkUpdateEnabled.text = EnabledType.from(checkUpdateEnabled).name + binding.checkUpdateEnabled.setSimpleItems(R.array.enabled) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..ff6e99d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt @@ -0,0 +1,209 @@ +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.constant.EnabledType +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.Profiles +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.text.DateFormat +import java.util.Date + +class EditProfileActivity : AbstractActivity() { + + private var _binding: ActivityEditProfileBinding? = null + private val binding get() = _binding!! + private var _profile: Profile? = null + private val profile get() = _profile!! + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_edit_profile) + _binding = ActivityEditProfileBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + loadProfile() + }.onFailure { + errorDialogBuilder(it) + .setPositiveButton(android.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 = Profiles.get(profileId) ?: error("invalid arguments") + withContext(Dispatchers.Main) { + binding.name.text = profile.name + binding.name.addTextChangedListener { + lifecycleScope.launch(Dispatchers.IO) { + try { + profile.name = it + Profiles.update(profile) + } catch (e: Exception) { + errorDialogBuilder(e).show() + } + } + } + binding.type.text = profile.typed.type.name + 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 = + DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) + binding.autoUpdate.text = EnabledType.from(profile.typed.autoUpdate).name + 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.checkButton.setOnClickListener(this@EditProfileActivity::checkProfile) + 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(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(200) + try { + Profiles.update(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + errorDialogBuilder(e).show() + } + } + withContext(Dispatchers.Main) { + binding.progressView.isVisible = false + } + } + } + + private fun updateProfile(view: View) { + binding.progressView.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + try { + val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } + Libbox.checkConfig(content) + File(profile.typed.path).writeText(content) + profile.typed.lastUpdated = Date() + Profiles.update(profile) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + errorDialogBuilder(e).show() + } + } + withContext(Dispatchers.Main) { + binding.lastUpdated.text = + DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) + binding.progressView.isVisible = false + } + } + } + + private fun checkProfile(button: View) { + binding.progressView.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + delay(200) + try { + Libbox.checkConfig(File(profile.typed.path).readText()) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + errorDialogBuilder(e).show() + } + } + withContext(Dispatchers.Main) { + binding.progressView.isVisible = false + } + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..7131c17 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt @@ -0,0 +1,144 @@ +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.Profiles +import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding +import io.nekohasekai.sfa.ktx.errorDialogBuilder +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 _binding: ActivityEditProfileContentBinding? = null + private val binding get() = _binding!! + + private var _profile: Profile? = null + private val profile get() = _profile!! + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.title_edit_configuration) + _binding = ActivityEditProfileContentBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.editor.language = JsonLanguage() + loadConfiguration() + } + + private fun loadConfiguration() { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + loadConfiguration0() + }.onFailure { + withContext(Dispatchers.Main) { + errorDialogBuilder(it) + .setPositiveButton(android.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()) + 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") + _profile = Profiles.get(profileId) ?: error("invalid arguments") + 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(200) + binding.progressView.isInvisible = true + } + } + } + binding.progressView.isInvisible = true + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..5dd5914 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt @@ -0,0 +1,177 @@ +package io.nekohasekai.sfa.ui.profile + +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.Profiles +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(val formatted: String) { + CreateNew("Create New"), + Import("Import"); + } + + private var _binding: ActivityAddProfileBinding? = null + private val binding get() = _binding!! + + 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) + _binding = ActivityAddProfileBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + binding.name.removeErrorIfNotEmpty() + binding.type.addTextChangedListener { + when (it) { + TypedProfile.Type.Local.name -> { + binding.localFields.isVisible = true + binding.remoteFields.isVisible = false + } + + TypedProfile.Type.Remote.name -> { + binding.localFields.isVisible = false + binding.remoteFields.isVisible = true + } + } + } + binding.fileSourceMenu.addTextChangedListener { + when (it) { + FileSource.CreateNew.formatted -> { + binding.importFileButton.isVisible = false + binding.sourceURL.isVisible = false + } + + FileSource.Import.formatted -> { + binding.importFileButton.isVisible = true + binding.sourceURL.isVisible = true + } + } + } + binding.importFileButton.setOnClickListener { + startFilesForResult(importFile, "application/json") + } + binding.createProfile.setOnClickListener(this::createProfile) + } + + private fun createProfile(view: View) { + if (binding.name.showErrorIfEmpty()) { + return + } + when (binding.type.text) { + TypedProfile.Type.Local.name -> { + when (binding.fileSourceMenu.text) { + FileSource.Import.formatted -> { + if (binding.sourceURL.showErrorIfEmpty()) { + return + } + } + } + } + + TypedProfile.Type.Remote.name -> { + 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 = Profiles.nextOrder() + + when (binding.type.text) { + TypedProfile.Type.Local.name -> { + typedProfile.type = TypedProfile.Type.Local + val configDirectory = File(filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "${profile.userOrder}.json") + when (binding.fileSourceMenu.text) { + FileSource.CreateNew.formatted -> { + configFile.writeText("{}") + } + + FileSource.Import.formatted -> { + 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.path = configFile.path + } + + TypedProfile.Type.Remote.name -> { + typedProfile.type = TypedProfile.Type.Remote + val configDirectory = File(filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "${profile.userOrder}.json") + val remoteURL = binding.remoteURL.text + val content = HTTPClient().use { it.getString(remoteURL) } + Libbox.checkConfig(content) + configFile.writeText(content) + typedProfile.path = configFile.path + typedProfile.remoteURL = remoteURL + typedProfile.lastUpdated = Date() + } + } + Profiles.create(profile) + withContext(Dispatchers.Main) { + binding.progressView.isVisible = false + finish() + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..112c679 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sfa.ui.shared + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import com.google.android.material.color.DynamicColors +import com.google.android.material.elevation.SurfaceColors +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.ktx.getAttrColor + + +abstract class AbstractActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + DynamicColors.applyToActivityIfAvailable(this) + + val color = SurfaceColors.SURFACE_2.getColor(this) + window.statusBarColor = color + window.navigationBarColor = color + + supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( + this@AbstractActivity, + R.drawable.ic_arrow_back_24 + )!!.apply { + setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) + }) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + return true + } + } + return super.onOptionsItemSelected(item) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt b/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt new file mode 100644 index 0000000..8d03180 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt @@ -0,0 +1,121 @@ +package io.nekohasekai.sfa.utils + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.ParcelableSpan +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.core.content.ContextCompat +import io.nekohasekai.sfa.R +import java.util.Stack + +object ColorUtils { + + private val ansiRegex by lazy { Regex("\u001B\\[[;\\d]*m") } + + fun ansiEscapeToSpannable(context: Context, text: String): Spannable { + val spannable = SpannableString(text.replace(ansiRegex, "")) + val stack = Stack() + val spans = mutableListOf() + val matches = ansiRegex.findAll(text) + var offset = 0 + + matches.forEach { result -> + val stringCode = result.value + val start = result.range.last + val end = result.range.last + 1 + val ansiInstruction = AnsiInstruction(context, stringCode) + offset += stringCode.length + if (ansiInstruction.decorationCode == "0" && stack.isNotEmpty()) { + spans.add(stack.pop().copy(end = end - offset)) + } else { + val span = AnsiSpan( + AnsiInstruction(context, stringCode), + start - if (offset > start) start else offset - 1, + 0 + ) + stack.push(span) + } + } + + spans.forEach { ansiSpan -> + ansiSpan.instruction.spans.forEach { + spannable.setSpan( + it, + ansiSpan.start, + ansiSpan.end, + Spannable.SPAN_EXCLUSIVE_INCLUSIVE + ) + } + } + + return spannable + } + + private data class AnsiSpan( + val instruction: AnsiInstruction, val start: Int, val end: Int + ) + + private class AnsiInstruction(context: Context, code: String) { + + val spans: List by lazy { + listOfNotNull( + getSpan(colorCode, context), getSpan(decorationCode, context) + ) + } + + var colorCode: String? = null + private set + + var decorationCode: String? = null + private set + + init { + val colorCodes = code.substringAfter('[').substringBefore('m').split(';') + + when (colorCodes.size) { + 3 -> { + colorCode = colorCodes[1] + decorationCode = colorCodes[2] + } + + 2 -> { + colorCode = colorCodes[0] + decorationCode = colorCodes[1] + } + + 1 -> decorationCode = colorCodes[0] + } + } + } + + private fun getSpan(code: String?, context: Context): ParcelableSpan? = when (code) { + "0", null -> null + "1" -> StyleSpan(Typeface.NORMAL) + "3" -> StyleSpan(Typeface.ITALIC) + "4" -> UnderlineSpan() + "30" -> ForegroundColorSpan(Color.BLACK) + "31" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_red)) + "32" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_green)) + "33" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_yellow)) + "34" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue)) + "35" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_purple)) + "36" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue_light)) + "37" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_white)) + else -> { + var codeInt = code.toIntOrNull() + if (codeInt != null) { + codeInt %= 125 + val row = codeInt / 36 + val column = codeInt % 36 + ForegroundColorSpan(Color.rgb(row * 51, column / 6 * 51, column % 6 * 51)) + } else { + null + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt new file mode 100644 index 0000000..7223472 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sfa.utils + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import java.io.Closeable + +class HTTPClient : Closeable { + + companion object { + val userAgent by lazy { + var userAgent = "SFA/" + userAgent += BuildConfig.VERSION_NAME + userAgent += " (" + userAgent += BuildConfig.VERSION_CODE + userAgent += "; sing-box " + userAgent += Libbox.version() + userAgent += ")" + userAgent + } + } + + private val client = Libbox.newHTTPClient() + + init { + client.modernTLS() + } + + fun getString(url: String): String { + val request = client.newRequest() + request.setUserAgent(userAgent) + request.setURL(url) + val response = request.execute() + return response.contentString + } + + override fun close() { + client.close() + } + + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back_24.xml b/app/src/main/res/drawable/ic_arrow_back_24.xml new file mode 100644 index 0000000..4a94ea3 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_dashboard_black_24dp.xml b/app/src/main/res/drawable/ic_dashboard_black_24dp.xml new file mode 100644 index 0000000..46fc8de --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_24.xml b/app/src/main/res/drawable/ic_delete_24.xml new file mode 100644 index 0000000..de011dd --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_24.xml b/app/src/main/res/drawable/ic_edit_24.xml new file mode 100644 index 0000000..7aeb747 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_insert_drive_file_24.xml b/app/src/main/res/drawable/ic_insert_drive_file_24.xml new file mode 100644 index 0000000..7b10cae --- /dev/null +++ b/app/src/main/res/drawable/ic_insert_drive_file_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2d4f402 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_message_24.xml b/app/src/main/res/drawable/ic_message_24.xml new file mode 100644 index 0000000..c20e247 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_24.xml b/app/src/main/res/drawable/ic_more_vert_24.xml new file mode 100644 index 0000000..39fbab5 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_note_add_24.xml b/app/src/main/res/drawable/ic_note_add_24.xml new file mode 100644 index 0000000..73a44ab --- /dev/null +++ b/app/src/main/res/drawable/ic_note_add_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_24.xml b/app/src/main/res/drawable/ic_play_arrow_24.xml new file mode 100644 index 0000000..c055f09 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_24.xml b/app/src/main/res/drawable/ic_settings_24.xml new file mode 100644 index 0000000..298a5a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop_24.xml b/app/src/main/res/drawable/ic_stop_24.xml new file mode 100644 index 0000000..449a289 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_add_profile.xml b/app/src/main/res/layout/activity_add_profile.xml new file mode 100644 index 0000000..2548b3c --- /dev/null +++ b/app/src/main/res/layout/activity_add_profile.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_edit_profile.xml b/app/src/main/res/layout/activity_edit_profile.xml new file mode 100644 index 0000000..125f2b8 --- /dev/null +++ b/app/src/main/res/layout/activity_edit_profile.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + +