Init commit

This commit is contained in:
世界 2022-12-02 14:17:47 +08:00
commit 7736e1e644
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
121 changed files with 6295 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/libs/

View file

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sing-box-for-android [:app:appCenterAssembleAndUploadRelease]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":app:appCenterAssembleAndUploadRelease" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sing-box-for-android [:app:assembleRelease]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":app:assembleRelease" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sing-box-for-android [:app:installDebug]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":app:installDebug" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

17
LICENSE Normal file
View file

@ -0,0 +1,17 @@
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
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 <http://www.gnu.org/licenses/>.
In addition, no derivative work may use the name or imply association
with this application without prior consent.

32
README.md Normal file
View file

@ -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 <contact-sagernet@sekai.icu>
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 <http://www.gnu.org/licenses/>.
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.

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

131
app/build.gradle Normal file
View file

@ -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 "";
}
}

21
app/proguard-rules.pro vendored Normal file
View file

@ -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

BIN
app/release.keystore Normal file

Binary file not shown.

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".Application"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:targetApi="31">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".ui.ShortcutActivity"
android:excludeFromRecents="true"
android:exported="true"
android:label="@string/quick_toggle"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/AppTheme.Translucent">
<intent-filter>
<action android:name="android.intent.action.CREATE_SHORTCUT" />
</intent-filter>
</activity>
<activity
android:name="io.nekohasekai.sfa.ui.profile.NewProfileActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.profile.EditProfileActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.ui.profile.EditProfileContentActivity"
android:exported="false" />
<service
android:name=".bg.TileService"
android:directBootAware="true"
android:exported="true"
android:icon="@drawable/ic_launcher_foreground"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:targetApi="n">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<service
android:name=".bg.VPNService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service
android:name=".bg.ProxyService"
android:exported="false" />
<receiver
android:name=".bg.BootReceiver"
android:exported="true">
<intent-filter android:priority="999">
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -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);
}

View file

@ -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<String> messages);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -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<NotificationManager>()!! }
val connectivity by lazy { application.getSystemService<ConnectivityManager>()!! }
val packageManager by lazy { application.packageManager }
}
}

View file

@ -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()
}
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,196 @@
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
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<Network>()
}
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<NetworkMessage>(Dispatchers.Unconfined) {
val listeners = mutableMapOf<Any, (Network?) -> Unit>()
var network: Network? = null
val pendingRequests = arrayListOf<NetworkMessage.Get>()
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)
}

View file

@ -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)
}
}

View file

@ -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<ByteArray> {
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<Collection<InetAddress>> {
@Suppress("ThrowableNotThrown")
override fun onAnswer(answer: Collection<InetAddress>, rcode: Int) {
if (rcode == 0) {
ctx.success((answer as Collection<InetAddress?>).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"))
}
}
}

View file

@ -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<NetworkInterface>) :
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<String>) : StringIterator {
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): String {
return iterator.next()
}
}
}

View file

@ -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)
}

View file

@ -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<Status>) : IService.Stub() {
private val callbacks = RemoteCallbackList<IServiceCallback>()
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()
}
}

View file

@ -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<String>) {}
}
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<String>) =
callback.onServiceResetLogs(messages)
}
}

View file

@ -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)
}
}

View file

@ -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 -> {}
}
}
}

View file

@ -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()
}
}
}
}

View file

@ -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)
}

View file

@ -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"
}

View file

@ -0,0 +1,10 @@
package io.nekohasekai.sfa.constant
enum class Alert {
RequestVPNPermission,
RequestNotificationPermission,
EmptyConfiguration,
StartCommandServer,
CreateService,
StartService
}

View file

@ -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
}
}
}

View file

@ -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"
}

View file

@ -0,0 +1,6 @@
package io.nekohasekai.sfa.constant
object ServiceMode {
const val NORMAL = "normal"
const val VPN = "vpn"
}

View file

@ -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"
}

View file

@ -0,0 +1,8 @@
package io.nekohasekai.sfa.constant
enum class Status {
Stopped,
Starting,
Started,
Stopping,
}

View file

@ -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<Profile>): Int
@Delete
fun delete(profile: Profile): Int
@Delete
fun delete(profile: List<Profile>): Int
@Query("SELECT * FROM profiles WHERE id = :profileId")
fun get(profileId: Long): Profile?
@Query("select * from profiles order by userOrder asc")
fun list(): List<Profile>
@Query("DELETE FROM profiles")
fun clear()
@Query("SELECT MAX(userOrder) + 1 FROM profiles")
fun nextOrder(): Long?
}
}

View file

@ -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
}

View file

@ -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<Profile>): Int {
return instance.profileDao().update(profiles)
}
suspend fun delete(profile: Profile): Int {
return instance.profileDao().delete(profile)
}
suspend fun delete(profiles: List<Profile>): Int {
return instance.profileDao().delete(profiles)
}
suspend fun list(): List<Profile> {
return instance.profileDao().list()
}
}

View file

@ -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
}
}

View file

@ -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<TypedProfile> {
override fun createFromParcel(parcel: Parcel): TypedProfile {
return TypedProfile(parcel)
}
override fun newArray(size: Int): Array<TypedProfile?> {
return arrayOfNulls(size)
}
}
class Convertor {
@TypeConverter
fun marshall(profile: TypedProfile) = profile.marshall()
@TypeConverter
fun unmarshall(content: ByteArray) =
content.unmarshall(::TypedProfile)
}
}

View file

@ -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
}

View file

@ -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<KeyValueEntity> {
override fun createFromParcel(parcel: Parcel): KeyValueEntity {
return KeyValueEntity(parcel)
}
override fun newArray(size: Int): Array<KeyValueEntity?> {
return arrayOfNulls(size)
}
}
}
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM KeyValueEntity")
fun all(): List<KeyValueEntity>
@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<KeyValueEntity>)
}
@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<String>?
get() = if (valueType == TYPE_STRING_SET) {
val buffer = ByteBuffer.wrap(value)
val result = HashSet<String>()
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<String>): 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
}
}

View file

@ -0,0 +1,7 @@
package io.nekohasekai.sfa.database.preference
import androidx.preference.PreferenceDataStore
interface OnPreferenceDataStoreChangeListener {
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
}

View file

@ -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<String>?) =
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<String>?) =
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<OnPreferenceDataStoreChangeListener>()
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)
}
}
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -0,0 +1,18 @@
package io.nekohasekai.sfa.ktx
import kotlin.coroutines.Continuation
fun <T> Continuation<T>.tryResume(value: T) {
try {
resumeWith(Result.success(value))
} catch (ignored: IllegalStateException) {
}
}
fun <T> Continuation<T>.tryResumeWithException(exception: Throwable) {
try {
resumeWith(Result.failure(exception))
} catch (ignored: IllegalStateException) {
}
}

View file

@ -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())
}

View file

@ -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() ?: "")
}
}
}

View file

@ -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<String>, 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()
}

View file

@ -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<T>(
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())!!
}

View file

@ -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 <T> 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
}

View file

@ -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<String>()
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<Intent, Boolean>() {
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<String>) {
logList.clear()
logList.addAll(messages)
if (!paused) logCallback?.invoke(true)
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
}
}

View file

@ -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<ShortcutManager>()?.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()
}
}

View file

@ -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<Holder>() {
internal var items: MutableList<Profile> = 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<Profile>()
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()
}
}
}
}

View file

@ -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<Holder>() {
internal var items: MutableList<Profile> = 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()
}
}
}
}
}

View file

@ -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<String>) :
RecyclerView.Adapter<LogViewHolder>() {
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)
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}

View file

@ -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<AnsiSpan>()
val spans = mutableListOf<AnsiSpan>()
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<ParcelableSpan> 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
}
}
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z" />
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View file

@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z" />
</vector>

View file

@ -0,0 +1,57 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1027"
android:viewportHeight="1109">
<group
android:scaleX="0.4284107"
android:scaleY="0.46261683"
android:translateX="295.71066"
android:translateY="300.35416">
<group>
<clip-path android:pathData="M0,0h1027v1109h-1027z" />
<path
android:pathData="M0,336 L0,720.7C0,785.4 39.5,796.2 39.5,796.2L456,1076.6C520.7,1119.8 502.7,1026.3 502.7,1026.3L502.7,674 0,336Z"
android:fillColor="#37474F"
android:fillType="evenOdd" />
<group>
<clip-path android:pathData="M490,325h541v786h-541z" />
<group>
<clip-path android:pathData="M492,327h535v782h-535z" />
<path
android:pathData="M1012.5,342.5 L1012.5,727.2C1012.5,791.9 973,802.7 973,802.7 973,802.7 621.1,1040 556.5,1083.1 491.8,1126.3 509.8,1032.8 509.8,1032.8L509.8,680.4 1012.5,342.5Z"
android:fillColor="#455A64"
android:fillType="evenOdd" />
</group>
</group>
<path
android:pathData="M1006,336 L1006,720.7C1006,785.4 966.5,796.2 966.5,796.2 966.5,796.2 614.6,1033.5 550,1076.6 485.3,1119.8 503.3,1026.3 503.3,1026.3L503.3,674 1006,336Z"
android:fillColor="#455A64"
android:fillType="evenOdd" />
<path
android:pathData="M549.7,13.5C521,-4.5 477.8,-4.5 452.7,13.5L21.6,308.1C-7.2,326 -7.2,358.4 21.6,376.3L452.7,674.5C481.4,692.5 524.6,692.5 549.7,674.5L984.4,372.7C1013.2,354.8 1013.2,322.4 984.4,304.5L549.7,13.5Z"
android:fillColor="#546E7A"
android:fillType="evenOdd" />
<path
android:pathData="M503,1094C481.4,1094 467,1080.2 467,1062.9L467,676.1C467,658.8 481.4,645 503,645 524.6,645 539,658.8 539,676.1L539,1059.5C539,1080.2 524.6,1094 503,1094Z"
android:fillColor="#546E7A"
android:fillType="evenOdd" />
<path
android:pathData="M861.9,580.9C861.9,616.9 865.5,631.3 826,656.4L736.3,714C696.8,739.2 682.5,717.6 682.5,685.2L682.5,591.7C682.5,584.5 682.5,580.9 671.7,573.7 578.4,509 219.6,260.8 155,214.1L320.1,99C366.7,127.8 707.6,354.3 847.6,451.4 854.7,455.1 858.3,462.2 858.3,465.8L858.3,580.9Z"
android:fillColor="#99AAB5"
android:fillType="evenOdd" />
<path
android:pathData="M851.4,455.2C707.8,358.2 366.8,131.8 323.7,103L259.1,142.5 155,214.4C219.6,261.1 578.6,505.5 671.9,570.2 679.1,573.8 679.1,577.4 679.1,581L855,458.8C855,458.8 855,455.2 851.4,455.2Z"
android:fillColor="#CCD6DD"
android:fillType="evenOdd" />
<path
android:pathData="M862.9,580.5 L862.9,469.2C862.9,462 859.3,458.4 852.1,454.8 708.3,357.9 366.7,131.7 323.5,103L248,153.3C370.3,235.8 697.5,451.2 783.8,512.3 794.6,519.4 794.6,526.6 794.6,530.2L794.6,681 830.5,655.9C866.5,630.7 862.9,612.8 862.9,580.5Z"
android:fillColor="#CCD6DD"
android:fillType="evenOdd" />
<path
android:pathData="M851.2,454.9C707.6,358 366.5,131.7 323.4,103L248,153.3C370.1,235.9 696.8,451.4 783,512.4 783,512.4 786.6,516 786.6,516L862,462.1C854.8,458.5 854.8,454.9 851.2,454.9Z"
android:fillColor="#E1E8ED"
android:fillType="evenOdd" />
</group>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,16h-3v3h-2v-3L8,16v-2h3v-3h2v3h3v2zM13,9L13,3.5L18.5,9L13,9z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h12v12H6z" />
</vector>

View file

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/profile_name">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/type"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_type">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/profile_type_local"
app:simpleItems="@array/profile_type" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/localFields"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/fileSourceMenu"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_source">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/profile_source_create_new"
app:simpleItems="@array/profile_source" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/importFileButton"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/profile_import_file"
android:visibility="gone">
</Button>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/sourceURL"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_url"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/remoteFields"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/remoteURL"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_url">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<Button
android:id="@+id/createProfile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/profile_create">
</Button>
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true" />
<LinearLayout
android:id="@+id/profileLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/profile_name">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/type"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:enabled="false"
android:hint="@string/profile_type">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/profile_type_local"
app:simpleItems="@array/profile_type" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/editButton"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/profile_edit_content" />
<LinearLayout
android:id="@+id/remoteFields"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/remoteURL"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_url">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/autoUpdate"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_auto_update">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/disabled"
app:simpleItems="@array/enabled" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/autoUpdateInterval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/profile_auto_update_interval">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/lastUpdated"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:enabled="false"
android:hint="@string/profile_last_updated">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/updateButton"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/profile_update" />
</LinearLayout>
<Button
android:id="@+id/checkButton"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/profile_check" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true" />
<com.blacksquircle.ui.editorkit.widget.TextProcessor
android:id="@+id/editor"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@null"
android:gravity="top|start"
android:padding="8dp"
android:textSize="14sp"
android:typeface="monospace" />
</LinearLayout>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<fragment
android:id="@+id/nav_host_fragment_activity_my"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:defaultNavHost="true"
app:navGraph="@navigation/mobile_navigation" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:menu="@menu/bottom_nav_menu" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.DashboardFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/profile_empty"
android:textSize="20sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profileList"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:backgroundTint="@color/blue_grey_600"
android:text="@string/title_new_profile"
android:textColor="@android:color/white"
app:icon="@drawable/ic_note_add_24"
app:iconTint="@android:color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.DashboardFragment">
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:visibility="gone"
android:text="@string/profile_empty"
android:textSize="20sp" />
<LinearLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/statusCard"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/profile_status"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<TextView
style="?attr/textAppearanceTitleSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/status_memory" />
<TextView
android:id="@+id/memoryText"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
style="?attr/textAppearanceTitleSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/status_goroutines" />
<TextView
android:id="@+id/goroutinesText"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/profileCard"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/profile"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/profileList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="16dp"
tools:itemCount="3"
tools:listitem="@layout/view_profile_item" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:backgroundTint="@color/blue_grey_600"
app:srcCompat="@drawable/ic_play_arrow_24"
app:tint="@android:color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.LogFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/status_default"
android:textSize="20sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/logView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:backgroundTint="@color/blue_grey_600"
app:srcCompat="@drawable/ic_play_arrow_24"
app:tint="@android:color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,212 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/appCenterCard"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_app_center"
android:textAppearance="?attr/textAppearanceTitleLarge" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/appCenterEnabled"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/enabled">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/disabled"
app:simpleItems="@array/enabled" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/checkUpdateEnabled"
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/check_update">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/disabled"
app:simpleItems="@array/enabled" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/statusCard"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/core"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<TextView
style="?attr/textAppearanceTitleSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/core_version" />
<TextView
android:id="@+id/versionText"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
tools:text="@string/loading" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
style="?attr/textAppearanceTitleSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/core_data_size" />
<TextView
android:id="@+id/dataSizeText"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/loading" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/clearButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_clear_working_directory"
android:textColor="#E91E63" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/aboutCard"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/about_title"
android:textAppearance="?attr/textAppearanceTitleLarge">
</TextView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/app_description" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/documentationButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/documentation_button" />
<Button
android:id="@+id/communityButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/community_button" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="4dp">
<TextView
android:id="@+id/profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
tools:text="Profile name" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"
android:orientation="horizontal">
<Button
android:id="@+id/moreButton"
style="?attr/materialIconButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_more_vert_24" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="?attr/materialCardViewElevatedStyle"
android:checkable="true"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:paddingLeft="12dp"
android:paddingTop="14dp"
android:paddingRight="12dp">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal">
<RadioButton
android:id="@+id/profile_selected"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
tools:text="Profile name" />
</LinearLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/navigation_dashboard"
android:icon="@drawable/ic_dashboard_black_24dp"
android:title="@string/title_dashboard" />
<item
android:id="@+id/navigation_log"
android:icon="@drawable/ic_message_24"
android:title="@string/title_log" />
<item
android:id="@+id/navigation_configuration"
android:icon="@drawable/ic_insert_drive_file_24"
android:title="@string/title_configuration" />
<item
android:id="@+id/navigation_settings"
android:icon="@drawable/ic_settings_24"
android:title="@string/title_settings" />
</menu>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_undo"
android:title="@string/menu_undo" />
<item
android:id="@+id/action_redo"
android:title="@string/menu_redo" />
<item
android:id="@+id/action_check"
android:title="@string/profile_check" />
<item
android:id="@+id/action_format"
android:title="@string/menu_format" />
</menu>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_delete"
android:title="@string/menu_delete"
android:icon="@drawable/ic_delete_24"
app:iconTintMode="src_in"
app:iconTint="?colorPrimary" />
</menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Some files were not shown because too many files have changed in this diff Show more