Init commit
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/libs/
|
|
@ -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>
|
23
.run/sing-box-for-android [_app_assembleRelease].run.xml
Normal 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>
|
23
.run/sing-box-for-android [_app_installDebug].run.xml
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1 @@
|
|||
/build
|
131
app/build.gradle
Normal 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
|
@ -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
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
106
app/src/main/AndroidManifest.xml
Normal 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>
|
9
app/src/main/aidl/io/nekohasekai/sfa/aidl/IService.aidl
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
}
|
BIN
app/src/main/ic_launcher-playstore.png
Normal file
After Width: | Height: | Size: 21 KiB |
40
app/src/main/java/io/nekohasekai/sfa/Application.kt
Normal 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 }
|
||||
}
|
||||
|
||||
}
|
30
app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
264
app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
134
app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
17
app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt
Normal 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)
|
||||
}
|
59
app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt
Normal 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()
|
||||
}
|
||||
}
|
115
app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
49
app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt
Normal 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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
92
app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
119
app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
Normal 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)
|
||||
|
||||
}
|
6
app/src/main/java/io/nekohasekai/sfa/constant/Action.kt
Normal 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"
|
||||
}
|
10
app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt
Normal file
|
@ -0,0 +1,10 @@
|
|||
package io.nekohasekai.sfa.constant
|
||||
|
||||
enum class Alert {
|
||||
RequestVPNPermission,
|
||||
RequestNotificationPermission,
|
||||
EmptyConfiguration,
|
||||
StartCommandServer,
|
||||
CreateService,
|
||||
StartService
|
||||
}
|
11
app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
6
app/src/main/java/io/nekohasekai/sfa/constant/Path.kt
Normal 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"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package io.nekohasekai.sfa.constant
|
||||
|
||||
object ServiceMode {
|
||||
const val NORMAL = "normal"
|
||||
const val VPN = "vpn"
|
||||
}
|
14
app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
Normal 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"
|
||||
|
||||
}
|
8
app/src/main/java/io/nekohasekai/sfa/constant/Status.kt
Normal file
|
@ -0,0 +1,8 @@
|
|||
package io.nekohasekai.sfa.constant
|
||||
|
||||
enum class Status {
|
||||
Stopped,
|
||||
Starting,
|
||||
Started,
|
||||
Stopping,
|
||||
}
|
58
app/src/main/java/io/nekohasekai/sfa/database/Profile.kt
Normal 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?
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
53
app/src/main/java/io/nekohasekai/sfa/database/Profiles.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
83
app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package io.nekohasekai.sfa.database.preference
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
|
||||
interface OnPreferenceDataStoreChangeListener {
|
||||
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
26
app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt
Normal 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))
|
||||
}
|
17
app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt
Normal 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
|
||||
}
|
18
app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt
Normal 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) {
|
||||
}
|
||||
}
|
24
app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt
Normal 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())
|
||||
}
|
51
app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt
Normal 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() ?: "")
|
||||
}
|
||||
}
|
||||
}
|
21
app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt
Normal 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()
|
||||
}
|
66
app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt
Normal 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())!!
|
||||
|
||||
}
|
21
app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt
Normal 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
|
||||
}
|
331
app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
67
app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
147
app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
108
app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
121
app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
41
app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt
Normal 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()
|
||||
}
|
||||
|
||||
|
||||
}
|
11
app/src/main/res/drawable/ic_arrow_back_24.xml
Normal 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>
|
9
app/src/main/res/drawable/ic_dashboard_black_24dp.xml
Normal 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>
|
5
app/src/main/res/drawable/ic_delete_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_edit_24.xml
Normal 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>
|
11
app/src/main/res/drawable/ic_insert_drive_file_24.xml
Normal 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>
|
57
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
5
app/src/main/res/drawable/ic_message_24.xml
Normal 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>
|
5
app/src/main/res/drawable/ic_more_vert_24.xml
Normal 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>
|
11
app/src/main/res/drawable/ic_note_add_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_play_arrow_24.xml
Normal 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>
|
5
app/src/main/res/drawable/ic_settings_24.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_stop_24.xml
Normal 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>
|
135
app/src/main/res/layout/activity_add_profile.xml
Normal 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>
|
149
app/src/main/res/layout/activity_edit_profile.xml
Normal 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>
|
23
app/src/main/res/layout/activity_edit_profile_content.xml
Normal 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>
|
32
app/src/main/res/layout/activity_main.xml
Normal 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>
|
43
app/src/main/res/layout/fragment_configuration.xml
Normal 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>
|
142
app/src/main/res/layout/fragment_dashboard.xml
Normal 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>
|
43
app/src/main/res/layout/fragment_log.xml
Normal 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>
|
212
app/src/main/res/layout/fragment_settings.xml
Normal 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>
|
52
app/src/main/res/layout/view_configutation_item.xml
Normal 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>
|
33
app/src/main/res/layout/view_log_text_item.xml
Normal 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>
|
23
app/src/main/res/layout/view_profile_item.xml
Normal 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>
|
23
app/src/main/res/menu/bottom_nav_menu.xml
Normal 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>
|
20
app/src/main/res/menu/edit_configutation_menu.xml
Normal 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>
|
12
app/src/main/res/menu/profile_menu.xml
Normal 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>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 5.9 KiB |