Commit 2e289597 authored by Mygod's avatar Mygod

Refactor using coroutines

parent c0ad5841
...@@ -5,6 +5,7 @@ apply plugin: 'com.github.ben-manes.versions' ...@@ -5,6 +5,7 @@ apply plugin: 'com.github.ben-manes.versions'
buildscript { buildscript {
ext { ext {
kotlinVersion = '1.3.11' kotlinVersion = '1.3.11'
kotlinCoroutinesVersion = '1.1.0'
minSdkVersion = 21 minSdkVersion = 21
sdkVersion = 28 sdkVersion = 28
compileSdkVersion = 28 compileSdkVersion = 28
......
...@@ -62,13 +62,15 @@ dependencies { ...@@ -62,13 +62,15 @@ dependencies {
api "android.arch.work:work-runtime-ktx:$workVersion" api "android.arch.work:work-runtime-ktx:$workVersion"
api "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" api "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
api "androidx.preference:preference:1.0.0" api 'androidx.preference:preference:1.0.0'
api "androidx.room:room-runtime:$roomVersion" api "androidx.room:room-runtime:$roomVersion"
api 'com.crashlytics.sdk.android:crashlytics:2.9.8' api 'com.crashlytics.sdk.android:crashlytics:2.9.8'
api 'com.google.firebase:firebase-config:16.1.3' api 'com.google.firebase:firebase-config:16.1.3'
api 'com.google.firebase:firebase-core:16.0.6' api 'com.google.firebase:firebase-core:16.0.6'
api 'com.squareup.okhttp3:okhttp:3.12.1' api 'com.squareup.okhttp3:okhttp:3.12.1'
api "com.takisoft.preferencex:preferencex:$preferencexVersion" api "com.takisoft.preferencex:preferencex:$preferencexVersion"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinCoroutinesVersion"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
testImplementation "androidx.arch.core:core-testing:$lifecycleVersion" testImplementation "androidx.arch.core:core-testing:$lifecycleVersion"
......
...@@ -51,6 +51,8 @@ import com.github.shadowsocks.utils.* ...@@ -51,6 +51,8 @@ import com.github.shadowsocks.utils.*
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import io.fabric.sdk.android.Fabric import io.fabric.sdk.android.Fabric
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.reflect.KClass import kotlin.reflect.KClass
...@@ -106,7 +108,7 @@ object Core { ...@@ -106,7 +108,7 @@ object Core {
// handle data restored/crash // handle data restored/crash
if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware && if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware &&
app.getSystemService<UserManager>()?.isUserUnlocked == true) DirectBoot.flushTrafficStats() app.getSystemService<UserManager>()?.isUserUnlocked == true) DirectBoot.flushTrafficStats()
if (DataStore.tcpFastOpen && !TcpFastOpen.sendEnabled) TcpFastOpen.enableAsync() if (DataStore.tcpFastOpen && !TcpFastOpen.sendEnabled) TcpFastOpen.enableTimeout()
if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) { if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) {
val assetManager = app.assets val assetManager = app.assets
for (dir in arrayOf("acl", "overture")) for (dir in arrayOf("acl", "overture"))
......
...@@ -39,8 +39,8 @@ import com.github.shadowsocks.plugin.PluginManager ...@@ -39,8 +39,8 @@ import com.github.shadowsocks.plugin.PluginManager
import com.github.shadowsocks.utils.Action import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.broadcastReceiver import com.github.shadowsocks.utils.broadcastReceiver
import com.github.shadowsocks.utils.printLog import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.thread
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.net.UnknownHostException import java.net.UnknownHostException
import java.util.* import java.util.*
...@@ -62,16 +62,16 @@ object BaseService { ...@@ -62,16 +62,16 @@ object BaseService {
const val CONFIG_FILE_UDP = "shadowsocks-udp.conf" const val CONFIG_FILE_UDP = "shadowsocks-udp.conf"
class Data internal constructor(private val service: Interface) { class Data internal constructor(private val service: Interface) {
@Volatile var state = STOPPED var state = STOPPED
val processes = GuardedProcessPool() var processes: GuardedProcessPool? = null
@Volatile var proxy: ProxyInstance? = null var proxy: ProxyInstance? = null
@Volatile var udpFallback: ProxyInstance? = null var udpFallback: ProxyInstance? = null
var notification: ServiceNotification? = null var notification: ServiceNotification? = null
val closeReceiver = broadcastReceiver { _, intent -> val closeReceiver = broadcastReceiver { _, intent ->
when (intent.action) { when (intent.action) {
Action.RELOAD -> service.forceLoad() Action.RELOAD -> service.forceLoad()
else -> service.stopRunner(true) else -> service.stopRunner()
} }
} }
var closeReceiverRegistered = false var closeReceiverRegistered = false
...@@ -101,11 +101,12 @@ object BaseService { ...@@ -101,11 +101,12 @@ object BaseService {
} }
private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) { private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) {
val n = callbacks.beginBroadcast() repeat(callbacks.beginBroadcast()) {
for (i in 0 until n) try { try {
work(callbacks.getBroadcastItem(i)) work(callbacks.getBroadcastItem(it))
} catch (e: Exception) { } catch (e: Exception) {
printLog(e) printLog(e)
}
} }
callbacks.finishBroadcast() callbacks.finishBroadcast()
} }
...@@ -193,26 +194,23 @@ object BaseService { ...@@ -193,26 +194,23 @@ object BaseService {
fun forceLoad() { fun forceLoad() {
val (profile, fallback) = Core.currentProfile val (profile, fallback) = Core.currentProfile
?: return stopRunner(true, (this as Context).getString(R.string.profile_empty)) ?: return stopRunner(false, (this as Context).getString(R.string.profile_empty))
if (profile.host.isEmpty() || profile.password.isEmpty() || if (profile.host.isEmpty() || profile.password.isEmpty() ||
fallback != null && (fallback.host.isEmpty() || fallback.password.isEmpty())) { fallback != null && (fallback.host.isEmpty() || fallback.password.isEmpty())) {
stopRunner(true, (this as Context).getString(R.string.proxy_empty)) stopRunner(false, (this as Context).getString(R.string.proxy_empty))
return return
} }
val s = data.state val s = data.state
when (s) { when (s) {
STOPPED -> startRunner() STOPPED -> startRunner()
CONNECTED -> { CONNECTED -> stopRunner(true)
stopRunner(false)
startRunner()
}
else -> Crashlytics.log(Log.WARN, tag, "Illegal state when invoking use: $s") else -> Crashlytics.log(Log.WARN, tag, "Illegal state when invoking use: $s")
} }
} }
fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> = cmd fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> = cmd
fun startNativeProcesses() { suspend fun startProcesses() {
val configRoot = (if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>() val configRoot = (if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>()
?.isUserUnlocked != false) app else Core.deviceStorage).noBackupFilesDir ?.isUserUnlocked != false) app else Core.deviceStorage).noBackupFilesDir
val udpFallback = data.udpFallback val udpFallback = data.udpFallback
...@@ -233,39 +231,45 @@ object BaseService { ...@@ -233,39 +231,45 @@ object BaseService {
else startService(Intent(this, javaClass)) else startService(Intent(this, javaClass))
} }
fun killProcesses() = data.processes.killAll() suspend fun killProcesses() {
data.processes?.run {
close()
data.processes = null
}
}
fun stopRunner(stopService: Boolean, msg: String? = null) { fun stopRunner(restart: Boolean = false, msg: String? = null) {
// channge the state // channge the state
val data = data
data.changeState(STOPPING) data.changeState(STOPPING)
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
Core.analytics.logEvent("stop", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag)))
Core.analytics.logEvent("stop", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag))) killProcesses()
killProcesses()
// clean up recevier // clean up recevier
this as Service this@Interface as Service
if (data.closeReceiverRegistered) { val data = data
unregisterReceiver(data.closeReceiver) if (data.closeReceiverRegistered) {
data.closeReceiverRegistered = false unregisterReceiver(data.closeReceiver)
} data.closeReceiverRegistered = false
}
data.notification?.destroy() data.notification?.destroy()
data.notification = null data.notification = null
val ids = listOfNotNull(data.proxy, data.udpFallback).map { val ids = listOfNotNull(data.proxy, data.udpFallback).map {
it.close() it.close()
it.profile.id it.profile.id
} }
data.proxy = null data.proxy = null
data.binder.trafficPersisted(ids) data.binder.trafficPersisted(ids)
// change the state // change the state
data.changeState(STOPPED, msg) data.changeState(STOPPED, msg)
// stop the service if nothing has bound to it // stop the service if nothing has bound to it
if (stopService) stopSelf() if (restart) startRunner() else stopSelf()
}
} }
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
...@@ -276,7 +280,7 @@ object BaseService { ...@@ -276,7 +280,7 @@ object BaseService {
if (profilePair == null) { if (profilePair == null) {
// gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
data.notification = createNotification("") data.notification = createNotification("")
stopRunner(true, getString(R.string.profile_empty)) stopRunner(false, getString(R.string.profile_empty))
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
} }
val (profile, fallback) = profilePair val (profile, fallback) = profilePair
...@@ -299,14 +303,14 @@ object BaseService { ...@@ -299,14 +303,14 @@ object BaseService {
data.changeState(CONNECTING) data.changeState(CONNECTING)
thread("$tag-Connecting") { GlobalScope.launch(Dispatchers.Main) {
try { try {
proxy.init() proxy.init()
data.udpFallback?.init() data.udpFallback?.init()
// Clean up
killProcesses()
startNativeProcesses() killProcesses()
data.processes = GuardedProcessPool()
startProcesses()
proxy.scheduleUpdate() proxy.scheduleUpdate()
data.udpFallback?.scheduleUpdate() data.udpFallback?.scheduleUpdate()
...@@ -314,12 +318,12 @@ object BaseService { ...@@ -314,12 +318,12 @@ object BaseService {
data.changeState(CONNECTED) data.changeState(CONNECTED)
} catch (_: UnknownHostException) { } catch (_: UnknownHostException) {
stopRunner(true, getString(R.string.invalid_server)) stopRunner(false, getString(R.string.invalid_server))
} catch (exc: Throwable) { } catch (exc: Throwable) {
if (exc !is PluginManager.PluginNotFoundException && exc !is VpnService.NullConnectionException) { if (exc !is PluginManager.PluginNotFoundException && exc !is VpnService.NullConnectionException) {
printLog(exc) printLog(exc)
} }
stopRunner(true, "${getString(R.string.service_failed)}: ${exc.localizedMessage}") stopRunner(false, "${getString(R.string.service_failed)}: ${exc.localizedMessage}")
} }
} }
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
......
...@@ -26,135 +26,104 @@ import android.system.ErrnoException ...@@ -26,135 +26,104 @@ import android.system.ErrnoException
import android.system.Os import android.system.Os
import android.system.OsConstants import android.system.OsConstants
import android.util.Log import android.util.Log
import androidx.annotation.MainThread
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.Core import com.github.shadowsocks.Core
import com.github.shadowsocks.utils.Commandline import com.github.shadowsocks.utils.Commandline
import com.github.shadowsocks.utils.thread import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.selects.select
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.ArrayBlockingQueue import kotlin.concurrent.thread
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.TimeUnit
class GuardedProcessPool { class GuardedProcessPool : CoroutineScope {
companion object Dummy : IOException("Oopsie the developer has made a no-no") { companion object {
private const val TAG = "GuardedProcessPool" private const val TAG = "GuardedProcessPool"
private val ProcessImpl by lazy { Class.forName("java.lang.ProcessManager\$ProcessImpl") } private val pid by lazy {
private val pid by lazy { ProcessImpl.getDeclaredField("pid").apply { isAccessible = true } } Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid").apply { isAccessible = true }
private val exitValueMutex by lazy {
ProcessImpl.getDeclaredField("exitValueMutex").apply { isAccessible = true }
} }
} }
private inner class Guard(private val cmd: List<String>, private val onRestartCallback: (() -> Unit)?) { private inner class Guard(private val cmd: List<String>) {
val cmdName = File(cmd.first()).nameWithoutExtension val abortChannel = Channel<Unit>()
val excQueue = ArrayBlockingQueue<IOException>(1) // ArrayBlockingQueue doesn't want null private val exitChannel = Channel<Int>()
private var pushed = false private val cmdName = File(cmd.first()).nameWithoutExtension
private var startTime: Long = -1
private lateinit var process: Process
private fun streamLogger(input: InputStream, logger: (String, String) -> Int) = private fun streamLogger(input: InputStream, logger: (String, String) -> Int) = try {
thread("StreamLogger-$cmdName") { input.bufferedReader().forEachLine { logger(TAG, it) }
try { } catch (_: IOException) { } // ignore
input.bufferedReader().forEachLine { logger(TAG, it) }
} catch (_: IOException) { } // ignore fun start() {
} startTime = SystemClock.elapsedRealtime()
private fun pushException(ioException: IOException?) { process = ProcessBuilder(cmd).directory(Core.deviceStorage.noBackupFilesDir).start()
if (pushed) return
excQueue.put(ioException ?: Dummy)
pushed = true
} }
fun looper(host: HashSet<Thread>) { suspend fun looper(onRestartCallback: (() -> Unit)?) {
var process: Process? = null var running = true
try { try {
var callback: (() -> Unit)? = null while (true) {
while (guardThreads.get() === host) { thread(name = "stderr-$cmdName") { streamLogger(process.errorStream, Log::e) }
Crashlytics.log(Log.DEBUG, TAG, "start process: " + Commandline.toString(cmd)) thread(name = "stdout-$cmdName") {
val startTime = SystemClock.elapsedRealtime() runBlocking {
streamLogger(process.inputStream, Log::i)
process = ProcessBuilder(cmd) exitChannel.send(process.waitFor()) // this thread also acts as a daemon thread for waitFor
.redirectErrorStream(true) }
.directory(Core.deviceStorage.noBackupFilesDir) }
.start() if (select {
abortChannel.onReceive { true } // prefer abort to save work
streamLogger(process.inputStream, Log::i) exitChannel.onReceive { false }
streamLogger(process.errorStream, Log::e) }) break
running = false
if (callback == null) callback = onRestartCallback else callback()
pushException(null)
process.waitFor()
if (SystemClock.elapsedRealtime() - startTime < 1000) { if (SystemClock.elapsedRealtime() - startTime < 1000) {
Crashlytics.log(Log.WARN, TAG, "process exit too fast, stop guard: $cmdName") Crashlytics.log(Log.WARN, TAG, "process exit too fast, stop guard: $cmdName")
break break
} }
Crashlytics.log(Log.DEBUG, TAG, "restart process: " + Commandline.toString(cmd))
start()
running = true
onRestartCallback?.invoke()
} }
} catch (_: InterruptedException) {
Crashlytics.log(Log.DEBUG, TAG, "thread interrupt, destroy process: $cmdName")
} catch (e: IOException) {
pushException(e)
} finally { } finally {
if (process != null) { if (!running) return // process already exited, nothing to be done
if (Build.VERSION.SDK_INT < 24) { if (Build.VERSION.SDK_INT < 24) {
val pid = pid.get(process) as Int val pid = pid.get(process) as Int
try { try {
Os.kill(pid, OsConstants.SIGTERM) Os.kill(pid, OsConstants.SIGTERM)
} catch (e: ErrnoException) { } catch (e: ErrnoException) {
if (e.errno != OsConstants.ESRCH) throw e if (e.errno != OsConstants.ESRCH) throw e
}
val mutex = exitValueMutex.get(process) as Object
synchronized(mutex) {
try {
process.exitValue()
} catch (e: IllegalThreadStateException) {
mutex.wait(500)
}
}
}
process.destroy() // kill the process
if (Build.VERSION.SDK_INT >= 26) {
val isKilled = process.waitFor(1L, TimeUnit.SECONDS) // wait for 1 second
if (!isKilled) {
process.destroyForcibly() // Force to kill the process if it's still alive
}
} }
if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return
process.waitFor() // ensure the process is destroyed
} }
pushException(null) process.destroy() // kill the process
if (Build.VERSION.SDK_INT >= 26) {
if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return
process.destroyForcibly() // Force to kill the process if it's still alive
}
exitChannel.receive()
} }
} }
} }
/** private val supervisor = SupervisorJob()
* This is an indication of which thread pool is being active. override val coroutineContext get() = Dispatchers.Main + supervisor
* Reading/writing this collection still needs an additional lock to prevent concurrent modification. private val guards = ArrayList<Guard>()
*/
private val guardThreads = AtomicReference<HashSet<Thread>>(HashSet()) @MainThread
suspend fun start(cmd: List<String>, onRestartCallback: (() -> Unit)? = null) {
fun start(cmd: List<String>, onRestartCallback: (() -> Unit)? = null): GuardedProcessPool { Crashlytics.log(Log.DEBUG, TAG, "start process: " + Commandline.toString(cmd))
val guard = Guard(cmd, onRestartCallback) val guard = Guard(cmd)
val guardThreads = guardThreads.get() guard.start()
synchronized(guardThreads) { guards += guard
guardThreads.add(thread("GuardThread-${guard.cmdName}") { launch(start = CoroutineStart.UNDISPATCHED) { guard.looper(onRestartCallback) }
guard.looper(guardThreads)
})
}
val ioException = guard.excQueue.take()
if (ioException !== Dummy) throw ioException
return this
} }
fun killAll() { @MainThread
val guardThreads = guardThreads.getAndSet(HashSet()) suspend fun close() {
synchronized(guardThreads) { guards.forEach { it.abortChannel.send(Unit) }
guardThreads.forEach { it.interrupt() } supervisor.children.forEach { it.join() }
try {
guardThreads.forEach { it.join() }
} catch (_: InterruptedException) { }
}
} }
} }
...@@ -32,8 +32,8 @@ import org.json.JSONObject ...@@ -32,8 +32,8 @@ import org.json.JSONObject
object LocalDnsService { object LocalDnsService {
interface Interface : BaseService.Interface { interface Interface : BaseService.Interface {
override fun startNativeProcesses() { override suspend fun startProcesses() {
super.startNativeProcesses() super.startProcesses()
val data = data val data = data
val profile = data.proxy!!.profile val profile = data.proxy!!.profile
...@@ -86,7 +86,7 @@ object LocalDnsService { ...@@ -86,7 +86,7 @@ object LocalDnsService {
}) })
} }
if (!profile.udpdns) data.processes.start(buildAdditionalArguments(arrayListOf( if (!profile.udpdns) data.processes!!.start(buildAdditionalArguments(arrayListOf(
File(app.applicationInfo.nativeLibraryDir, Executable.OVERTURE).absolutePath, File(app.applicationInfo.nativeLibraryDir, Executable.OVERTURE).absolutePath,
"-c", buildOvertureConfig("overture.conf")))) "-c", buildOvertureConfig("overture.conf"))))
} }
......
...@@ -30,7 +30,12 @@ import com.github.shadowsocks.database.ProfileManager ...@@ -30,7 +30,12 @@ import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.PluginConfiguration import com.github.shadowsocks.plugin.PluginConfiguration
import com.github.shadowsocks.plugin.PluginManager import com.github.shadowsocks.plugin.PluginManager
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.* import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.parseNumericAddress
import com.github.shadowsocks.utils.signaturesCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
...@@ -39,7 +44,6 @@ import java.io.IOException ...@@ -39,7 +44,6 @@ import java.io.IOException
import java.net.InetAddress import java.net.InetAddress
import java.net.UnknownHostException import java.net.UnknownHostException
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/** /**
* This class sets up environment for ss-local. * This class sets up environment for ss-local.
...@@ -50,13 +54,9 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro ...@@ -50,13 +54,9 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
private val plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions private val plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions
val pluginPath by lazy { PluginManager.init(plugin) } val pluginPath by lazy { PluginManager.init(plugin) }
fun init() { suspend fun init() {
if (profile.host == "198.199.101.152") { if (profile.host == "198.199.101.152") {
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder().build()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val mdg = MessageDigest.getInstance("SHA-1") val mdg = MessageDigest.getInstance("SHA-1")
mdg.update(Core.packageInfo.signaturesCompat.first().toByteArray()) mdg.update(Core.packageInfo.signaturesCompat.first().toByteArray())
val requestBody = FormBody.Builder() val requestBody = FormBody.Builder()
...@@ -67,8 +67,9 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro ...@@ -67,8 +67,9 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
.post(requestBody) .post(requestBody)
.build() .build()
val proxies = client.newCall(request).execute() val proxies = withTimeout(30_000) {
.body()!!.string().split('|').toMutableList() withContext(Dispatchers.IO) { client.newCall(request).execute().body()!!.string() }
}.split('|').toMutableList()
proxies.shuffle() proxies.shuffle()
val proxy = proxies.first().split(':') val proxy = proxies.first().split(':')
profile.host = proxy[0].trim() profile.host = proxy[0].trim()
...@@ -80,22 +81,16 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro ...@@ -80,22 +81,16 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
if (route == Acl.CUSTOM_RULES) Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10)) if (route == Acl.CUSTOM_RULES) Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10))
// it's hard to resolve DNS on a specific interface so we'll do it here // it's hard to resolve DNS on a specific interface so we'll do it here
if (profile.host.parseNumericAddress() == null) { if (profile.host.parseNumericAddress() == null) profile.host = withTimeout(10_000) {
thread("ProxyInstance-resolve") { withContext(Dispatchers.IO) { InetAddress.getByName(profile.host).hostAddress }
// A WAR fix for Huawei devices that UnknownHostException cannot be caught correctly } ?: throw UnknownHostException()
try {
profile.host = InetAddress.getByName(profile.host).hostAddress ?: ""
} catch (_: UnknownHostException) { }
}.join(10 * 1000)
if (profile.host.parseNumericAddress() == null) throw UnknownHostException()
}
} }
/** /**
* Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or * Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or
* device storage, depending on which is currently available. * device storage, depending on which is currently available.
*/ */
fun start(service: BaseService.Interface, stat: File, configFile: File, extraFlag: String? = null) { suspend fun start(service: BaseService.Interface, stat: File, configFile: File, extraFlag: String? = null) {
trafficMonitor = TrafficMonitor(stat) trafficMonitor = TrafficMonitor(stat)
this.configFile = configFile this.configFile = configFile
...@@ -122,7 +117,7 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro ...@@ -122,7 +117,7 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
if (DataStore.tcpFastOpen) cmd += "--fast-open" if (DataStore.tcpFastOpen) cmd += "--fast-open"
service.data.processes.start(cmd) service.data.processes!!.start(cmd)
} }
fun scheduleUpdate() { fun scheduleUpdate() {
......
...@@ -36,7 +36,7 @@ class TransproxyService : Service(), LocalDnsService.Interface { ...@@ -36,7 +36,7 @@ class TransproxyService : Service(), LocalDnsService.Interface {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId) super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId)
private fun startDNSTunnel() { private suspend fun startDNSTunnel() {
val proxy = data.proxy!! val proxy = data.proxy!!
val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).absolutePath, val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).absolutePath,
"-t", "10", "-t", "10",
...@@ -47,10 +47,10 @@ class TransproxyService : Service(), LocalDnsService.Interface { ...@@ -47,10 +47,10 @@ class TransproxyService : Service(), LocalDnsService.Interface {
// config is already built by BaseService.Interface // config is already built by BaseService.Interface
"-c", (data.udpFallback ?: proxy).configFile!!.absolutePath) "-c", (data.udpFallback ?: proxy).configFile!!.absolutePath)
if (DataStore.tcpFastOpen) cmd += "--fast-open" if (DataStore.tcpFastOpen) cmd += "--fast-open"
data.processes.start(cmd) data.processes!!.start(cmd)
} }
private fun startRedsocksDaemon() { private suspend fun startRedsocksDaemon() {
File(Core.deviceStorage.noBackupFilesDir, "redsocks.conf").writeText("""base { File(Core.deviceStorage.noBackupFilesDir, "redsocks.conf").writeText("""base {
log_debug = off; log_debug = off;
log_info = off; log_info = off;
...@@ -66,13 +66,13 @@ redsocks { ...@@ -66,13 +66,13 @@ redsocks {
type = socks5; type = socks5;
} }
""") """)
data.processes.start(listOf( data.processes!!.start(listOf(
File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath, "-c", "redsocks.conf")) File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath, "-c", "redsocks.conf"))
} }
override fun startNativeProcesses() { override suspend fun startProcesses() {
startRedsocksDaemon() startRedsocksDaemon()
super.startNativeProcesses() super.startProcesses()
if (data.proxy!!.profile.udpdns) startDNSTunnel() if (data.proxy!!.profile.udpdns) startDNSTunnel()
} }
......
...@@ -139,9 +139,9 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { ...@@ -139,9 +139,9 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface {
else -> super<LocalDnsService.Interface>.onBind(intent) else -> super<LocalDnsService.Interface>.onBind(intent)
} }
override fun onRevoke() = stopRunner(true) override fun onRevoke() = stopRunner()
override fun killProcesses() { override suspend fun killProcesses() {
if (listeningForDefaultNetwork) { if (listeningForDefaultNetwork) {
connectivity.unregisterNetworkCallback(defaultNetworkCallback) connectivity.unregisterNetworkCallback(defaultNetworkCallback)
listeningForDefaultNetwork = false listeningForDefaultNetwork = false
...@@ -159,16 +159,16 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { ...@@ -159,16 +159,16 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface {
startActivity(Intent(this, VpnRequestActivity::class.java) startActivity(Intent(this, VpnRequestActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
else return super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId) else return super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId)
stopRunner(true) stopRunner()
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
} }
override fun startNativeProcesses() { override suspend fun startProcesses() {
val worker = ProtectWorker() val worker = ProtectWorker()
worker.start() worker.start()
this.worker = worker this.worker = worker
super.startNativeProcesses() super.startProcesses()
sendFd(startVpn()) sendFd(startVpn())
} }
...@@ -178,7 +178,7 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { ...@@ -178,7 +178,7 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface {
return cmd return cmd
} }
private fun startVpn(): FileDescriptor { private suspend fun startVpn(): FileDescriptor {
val profile = data.proxy!!.profile val profile = data.proxy!!.profile
val builder = Builder() val builder = Builder()
.setConfigureIntent(Core.configureIntent(this)) .setConfigureIntent(Core.configureIntent(this))
...@@ -247,11 +247,11 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { ...@@ -247,11 +247,11 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface {
cmd += "--dnsgw" cmd += "--dnsgw"
cmd += "127.0.0.1:${DataStore.portLocalDns}" cmd += "127.0.0.1:${DataStore.portLocalDns}"
} }
data.processes.start(cmd, onRestartCallback = { data.processes!!.start(cmd, onRestartCallback = {
try { try {
sendFd(conn.fileDescriptor) sendFd(conn.fileDescriptor)
} catch (e: ErrnoException) { } catch (e: ErrnoException) {
stopRunner(true, e.message) stopRunner(false, e.message)
} }
}) })
return conn.fileDescriptor return conn.fileDescriptor
......
...@@ -26,9 +26,9 @@ import androidx.lifecycle.ViewModel ...@@ -26,9 +26,9 @@ import androidx.lifecycle.ViewModel
import com.github.shadowsocks.Core import com.github.shadowsocks.Core
import com.github.shadowsocks.Core.app import com.github.shadowsocks.Core.app
import com.github.shadowsocks.acl.Acl import com.github.shadowsocks.acl.Acl
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.core.R import com.github.shadowsocks.core.R
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import kotlinx.coroutines.*
import java.io.IOException import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.InetSocketAddress import java.net.InetSocketAddress
...@@ -72,41 +72,47 @@ class HttpsTest : ViewModel() { ...@@ -72,41 +72,47 @@ class HttpsTest : ViewModel() {
} }
} }
private var testCount = 0 private var running: Pair<HttpURLConnection, Job>? = null
val status = MutableLiveData<Status>().apply { value = Status.Idle } val status = MutableLiveData<Status>().apply { value = Status.Idle }
fun testConnection() { fun testConnection() {
++testCount cancelTest()
status.value = Status.Testing status.value = Status.Testing
val id = testCount // it would change by other code val url = URL("https", when (Core.currentProfile!!.first.route) {
thread("ConnectionTest") { Acl.CHINALIST -> "www.qualcomm.cn"
val url = URL("https", when (Core.currentProfile!!.first.route) { else -> "www.google.com"
Acl.CHINALIST -> "www.qualcomm.cn" }, "/generate_204")
else -> "www.google.com" val conn = (if (DataStore.serviceMode == Key.modeVpn) url.openConnection() else
}, "/generate_204") url.openConnection(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", DataStore.portProxy))))
val conn = (if (DataStore.serviceMode == Key.modeVpn) url.openConnection() else as HttpURLConnection
url.openConnection(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", DataStore.portProxy)))) conn.setRequestProperty("Connection", "close")
as HttpURLConnection conn.instanceFollowRedirects = false
conn.setRequestProperty("Connection", "close") conn.useCaches = false
conn.instanceFollowRedirects = false running = conn to GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
conn.useCaches = false status.value = withContext(Dispatchers.IO) {
val result = try { try {
val start = SystemClock.elapsedRealtime() val start = SystemClock.elapsedRealtime()
val code = conn.responseCode val code = conn.responseCode
val elapsed = SystemClock.elapsedRealtime() - start val elapsed = SystemClock.elapsedRealtime() - start
if (code == 204 || code == 200 && conn.responseLength == 0L) Status.Success(elapsed) if (code == 204 || code == 200 && conn.responseLength == 0L) Status.Success(elapsed)
else Status.Error.UnexpectedResponseCode(code) else Status.Error.UnexpectedResponseCode(code)
} catch (e: IOException) { } catch (e: IOException) {
Status.Error.IOFailure(e) Status.Error.IOFailure(e)
} finally { } finally {
conn.disconnect() conn.disconnect()
}
} }
if (testCount == id) status.postValue(result)
} }
} }
private fun cancelTest() = running?.let { (conn, job) ->
job.cancel() // ensure job is cancelled before interrupting
conn.disconnect()
running = null
}
fun invalidate() { fun invalidate() {
++testCount cancelTest()
status.value = Status.Idle status.value = Status.Idle
} }
} }
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
package com.github.shadowsocks.utils package com.github.shadowsocks.utils
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
...@@ -58,5 +60,5 @@ object TcpFastOpen { ...@@ -58,5 +60,5 @@ object TcpFastOpen {
e.localizedMessage e.localizedMessage
} }
} }
fun enableAsync() = thread("TcpFastOpen") { enable() }.join(1000) fun enableTimeout() = runBlocking { withTimeoutOrNull(1000) { enable() } }
} }
...@@ -54,17 +54,6 @@ fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver = ...@@ -54,17 +54,6 @@ fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver =
override fun onReceive(context: Context, intent: Intent) = callback(context, intent) override fun onReceive(context: Context, intent: Intent) = callback(context, intent)
} }
/**
* Wrapper for kotlin.concurrent.thread that tracks uncaught exceptions.
*/
fun thread(name: String? = null, start: Boolean = true, isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null, priority: Int = -1, block: () -> Unit): Thread {
val thread = kotlin.concurrent.thread(false, isDaemon, contextClassLoader, name, priority, block)
thread.setUncaughtExceptionHandler { _, t -> printLog(t) }
if (start) thread.start()
return thread
}
val URLConnection.responseLength: Long val URLConnection.responseLength: Long
get() = if (Build.VERSION.SDK_INT >= 24) contentLengthLong else contentLength.toLong() get() = if (Build.VERSION.SDK_INT >= 24) contentLengthLong else contentLength.toLong()
......
...@@ -32,10 +32,10 @@ import android.content.pm.PackageInfo ...@@ -32,10 +32,10 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.view.* import android.view.*
import android.widget.ImageView import android.widget.ImageView
import android.widget.Switch import android.widget.Switch
import androidx.annotation.UiThread
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
...@@ -47,9 +47,8 @@ import com.github.shadowsocks.database.ProfileManager ...@@ -47,9 +47,8 @@ import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.Key import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.thread
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.*
class AppManager : AppCompatActivity() { class AppManager : AppCompatActivity() {
companion object { companion object {
...@@ -58,13 +57,13 @@ class AppManager : AppCompatActivity() { ...@@ -58,13 +57,13 @@ class AppManager : AppCompatActivity() {
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
private var cachedApps: List<PackageInfo>? = null private var cachedApps: List<PackageInfo>? = null
private fun getApps(pm: PackageManager) = synchronized(AppManager) { private suspend fun getApps(pm: PackageManager) = synchronized(AppManager) {
if (receiver == null) receiver = Core.listenForPackageChanges { if (receiver == null) receiver = Core.listenForPackageChanges {
synchronized(AppManager) { synchronized(AppManager) {
receiver = null receiver = null
cachedApps = null cachedApps = null
} }
AppManager.instance?.reloadApps() AppManager.instance?.loadApps()
} }
// Labels and icons can change on configuration (locale, etc.) changes, therefore they are not cached. // Labels and icons can change on configuration (locale, etc.) changes, therefore they are not cached.
val cachedApps = cachedApps ?: pm.getInstalledPackages(PackageManager.GET_PERMISSIONS) val cachedApps = cachedApps ?: pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
...@@ -72,7 +71,10 @@ class AppManager : AppCompatActivity() { ...@@ -72,7 +71,10 @@ class AppManager : AppCompatActivity() {
it.requestedPermissions?.contains(Manifest.permission.INTERNET) ?: false } it.requestedPermissions?.contains(Manifest.permission.INTERNET) ?: false }
this.cachedApps = cachedApps this.cachedApps = cachedApps
cachedApps cachedApps
}.map { ProxiedApp(pm, it.applicationInfo, it.packageName) } }.map {
yield()
ProxiedApp(pm, it.applicationInfo, it.packageName)
}
} }
private class ProxiedApp(private val pm: PackageManager, private val appInfo: ApplicationInfo, private class ProxiedApp(private val pm: PackageManager, private val appInfo: ApplicationInfo,
...@@ -106,17 +108,15 @@ class AppManager : AppCompatActivity() { ...@@ -106,17 +108,15 @@ class AppManager : AppCompatActivity() {
proxiedApps.add(item.packageName) proxiedApps.add(item.packageName)
check.isChecked = true check.isChecked = true
} }
if (!appsLoading.get()) { DataStore.individual = proxiedApps.joinToString("\n")
DataStore.individual = proxiedApps.joinToString("\n") DataStore.dirty = true
DataStore.dirty = true
}
} }
} }
private inner class AppsAdapter : RecyclerView.Adapter<AppViewHolder>() { private inner class AppsAdapter : RecyclerView.Adapter<AppViewHolder>() {
private var apps = listOf<ProxiedApp>() private var apps = listOf<ProxiedApp>()
fun reload() { suspend fun reload() {
apps = getApps(packageManager) apps = getApps(packageManager)
.sortedWith(compareBy({ !proxiedApps.contains(it.packageName) }, { it.name.toString() })) .sortedWith(compareBy({ !proxiedApps.contains(it.packageName) }, { it.name.toString() }))
} }
...@@ -132,38 +132,36 @@ class AppManager : AppCompatActivity() { ...@@ -132,38 +132,36 @@ class AppManager : AppCompatActivity() {
private lateinit var bypassSwitch: Switch private lateinit var bypassSwitch: Switch
private lateinit var appListView: RecyclerView private lateinit var appListView: RecyclerView
private lateinit var loadingView: View private lateinit var loadingView: View
private val appsLoading = AtomicBoolean()
private val handler = Handler()
private val clipboard by lazy { getSystemService<ClipboardManager>()!! } private val clipboard by lazy { getSystemService<ClipboardManager>()!! }
private var loader: Job? = null
private val shortAnimTime by lazy { resources.getInteger(android.R.integer.config_shortAnimTime).toLong() }
private fun View.crossFadeFrom(other: View) {
clearAnimation()
other.clearAnimation()
if (visibility == View.VISIBLE && other.visibility == View.GONE) return
alpha = 0F
visibility = View.VISIBLE
animate().alpha(1F).duration = shortAnimTime
other.animate().alpha(0F).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
other.visibility = View.GONE
}
}).duration = shortAnimTime
}
private fun initProxiedApps(str: String = DataStore.individual) { private fun initProxiedApps(str: String = DataStore.individual) {
proxiedApps = str.split('\n').toHashSet() proxiedApps = str.split('\n').toHashSet()
} }
private fun reloadApps() { @UiThread
if (!appsLoading.compareAndSet(true, false)) loadAppsAsync() private fun loadApps() {
} loader?.cancel()
private fun loadAppsAsync() { loader = GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
if (!appsLoading.compareAndSet(false, true)) return loadingView.crossFadeFrom(appListView)
appListView.visibility = View.GONE
loadingView.visibility = View.VISIBLE
thread("AppManager-loader") {
val adapter = appListView.adapter as AppsAdapter val adapter = appListView.adapter as AppsAdapter
do { withContext(Dispatchers.IO) { adapter.reload() }
appsLoading.set(true) adapter.notifyDataSetChanged()
adapter.reload() appListView.crossFadeFrom(loadingView)
} while (!appsLoading.compareAndSet(true, false))
handler.post {
adapter.notifyDataSetChanged()
val shortAnimTime = resources.getInteger(android.R.integer.config_shortAnimTime)
appListView.alpha = 0F
appListView.visibility = View.VISIBLE
appListView.animate().alpha(1F).duration = shortAnimTime.toLong()
loadingView.animate().alpha(0F).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
loadingView.visibility = View.GONE
}
}).duration = shortAnimTime.toLong()
}
} }
} }
...@@ -199,7 +197,7 @@ class AppManager : AppCompatActivity() { ...@@ -199,7 +197,7 @@ class AppManager : AppCompatActivity() {
appListView.adapter = AppsAdapter() appListView.adapter = AppsAdapter()
instance = this instance = this
loadAppsAsync() loadApps()
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
...@@ -239,7 +237,7 @@ class AppManager : AppCompatActivity() { ...@@ -239,7 +237,7 @@ class AppManager : AppCompatActivity() {
DataStore.dirty = true DataStore.dirty = true
Snackbar.make(appListView, R.string.action_import_msg, Snackbar.LENGTH_LONG).show() Snackbar.make(appListView, R.string.action_import_msg, Snackbar.LENGTH_LONG).show()
initProxiedApps(apps) initProxiedApps(apps)
reloadApps() loadApps()
return true return true
} catch (_: IllegalArgumentException) { } } catch (_: IllegalArgumentException) { }
} }
...@@ -255,7 +253,7 @@ class AppManager : AppCompatActivity() { ...@@ -255,7 +253,7 @@ class AppManager : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
instance = null instance = null
handler.removeCallbacksAndMessages(null) loader?.cancel()
super.onDestroy() super.onDestroy()
} }
} }
...@@ -55,11 +55,13 @@ ...@@ -55,11 +55,13 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="fill_parent" android:layout_height="fill_parent"
android:id="@+id/list" android:id="@+id/list"
android:visibility="gone"
app:fastScrollEnabled="true" app:fastScrollEnabled="true"
app:fastScrollHorizontalThumbDrawable="@drawable/fastscroll_thumb" app:fastScrollHorizontalThumbDrawable="@drawable/fastscroll_thumb"
app:fastScrollHorizontalTrackDrawable="@drawable/fastscroll_line" app:fastScrollHorizontalTrackDrawable="@drawable/fastscroll_line"
app:fastScrollVerticalThumbDrawable="@drawable/fastscroll_thumb" app:fastScrollVerticalThumbDrawable="@drawable/fastscroll_thumb"
app:fastScrollVerticalTrackDrawable="@drawable/fastscroll_line" app:fastScrollVerticalTrackDrawable="@drawable/fastscroll_line"
tools:listitem="@layout/layout_apps_item"/> tools:listitem="@layout/layout_apps_item"
tools:visibility="visible"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout> </LinearLayout>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment