Init commit
This commit is contained in:
40
app/src/main/java/io/nekohasekai/sfa/Application.kt
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user