Commit be6cf63d authored by Mygod's avatar Mygod

Merge branch 'master' into preference-1.1

parents f4171711 dca0da09
......@@ -9,14 +9,11 @@ buildscript {
sdkVersion = 28
compileSdkVersion = 28
buildToolsVersion = '28.0.3'
lifecycleVersion = '2.0.0'
roomVersion = '2.0.0'
workVersion = '1.0.0-beta05'
junitVersion = '4.12'
androidTestVersion = '1.1.1'
androidEspressoVersion = '3.1.1'
versionCode = 4070050
versionName = '4.7.0-nightly'
versionCode = 4070150
versionName = '4.7.1-nightly'
}
repositories {
......@@ -28,6 +25,7 @@ buildscript {
}
dependencies {
classpath 'com.vanniktech:gradle-maven-publish-plugin:0.8.0'
classpath 'com.android.tools.build:gradle:3.3.1'
classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0'
classpath 'com.google.gms:google-services:4.2.0'
......
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
......@@ -38,6 +39,13 @@ android {
}
}
androidExtensions {
experimental = true
}
def lifecycleVersion = '2.0.0'
def roomVersion = '2.0.0'
def workVersion = '1.0.0-rc02'
dependencies {
api project(':plugin')
api "android.arch.work:work-runtime-ktx:$workVersion"
......@@ -46,7 +54,7 @@ dependencies {
api 'androidx.preference:preference:1.1.0-alpha03'
api "androidx.room:room-runtime:$roomVersion"
api 'com.crashlytics.sdk.android:crashlytics:2.9.9'
api 'com.google.firebase:firebase-config:16.3.0'
api 'com.google.firebase:firebase-config:16.1.3'
api 'com.google.firebase:firebase-core:16.0.7'
api 'dnsjava:dnsjava:2.1.8'
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
......
This diff is collapsed.
......@@ -32,6 +32,7 @@ import java.io.File
import java.io.IOException
import java.io.Reader
import java.net.URL
import java.net.URLConnection
class Acl {
companion object {
......@@ -151,11 +152,11 @@ class Acl {
fromReader(getFile(id).bufferedReader())
} catch (_: IOException) { this }
fun flatten(depth: Int): Acl {
suspend fun flatten(depth: Int, connect: suspend (URL) -> URLConnection): Acl {
if (depth > 0) for (url in urls.asIterable()) {
val child = Acl()
try {
child.fromReader(url.openStream().bufferedReader(), bypass).flatten(depth - 1)
child.fromReader(connect(url).getInputStream().bufferedReader(), bypass).flatten(depth - 1, connect)
} catch (e: IOException) {
e.printStackTrace()
continue
......
......@@ -28,6 +28,7 @@ import android.os.DeadObjectException
import android.os.Handler
import android.os.IBinder
import android.os.RemoteException
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.bg.ProxyService
import com.github.shadowsocks.bg.TransproxyService
import com.github.shadowsocks.bg.VpnService
......@@ -51,7 +52,7 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
}
interface Callback {
fun stateChanged(state: Int, profileName: String?, msg: String?)
fun stateChanged(state: BaseService.State, profileName: String?, msg: String?)
fun trafficUpdated(profileId: Long, stats: TrafficStats) { }
fun trafficPersisted(profileId: Long) { }
......@@ -68,13 +69,16 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
private var callback: Callback? = null
private val serviceCallback = object : IShadowsocksServiceCallback.Stub() {
override fun stateChanged(state: Int, profileName: String?, msg: String?) {
handler.post { callback!!.stateChanged(state, profileName, msg) }
val callback = callback ?: return
handler.post { callback.stateChanged(BaseService.State.values()[state], profileName, msg) }
}
override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
handler.post { callback!!.trafficUpdated(profileId, stats) }
val callback = callback ?: return
handler.post { callback.trafficUpdated(profileId, stats) }
}
override fun trafficPersisted(profileId: Long) {
handler.post { callback!!.trafficPersisted(profileId) }
val callback = callback ?: return
handler.post { callback.trafficPersisted(profileId) }
}
}
private var binder: IBinder? = null
......@@ -105,14 +109,14 @@ class ShadowsocksConnection(private val handler: Handler = Handler(),
override fun onServiceDisconnected(name: ComponentName?) {
unregisterCallback()
callback!!.onServiceDisconnected()
callback?.onServiceDisconnected()
service = null
binder = null
}
override fun binderDied() {
service = null
handler.post(callback!!::onBinderDied)
callback?.also { handler.post(it::onBinderDied) }
}
private fun unregisterCallback() {
......
......@@ -39,9 +39,11 @@ import com.github.shadowsocks.plugin.PluginManager
import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.broadcastReceiver
import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.readableMessage
import com.google.firebase.analytics.FirebaseAnalytics
import kotlinx.coroutines.*
import java.io.File
import java.net.BindException
import java.net.InetAddress
import java.net.URL
import java.net.UnknownHostException
......@@ -51,20 +53,22 @@ import java.util.*
* This object uses WeakMap to simulate the effects of multi-inheritance.
*/
object BaseService {
/**
* IDLE state is only used by UI and will never be returned by BaseService.
*/
const val IDLE = 0
const val CONNECTING = 1
const val CONNECTED = 2
const val STOPPING = 3
const val STOPPED = 4
enum class State(val canStop: Boolean = false) {
/**
* Idle state is only used by UI and will never be returned by BaseService.
*/
Idle,
Connecting(true),
Connected(true),
Stopping,
Stopped,
}
const val CONFIG_FILE = "shadowsocks.conf"
const val CONFIG_FILE_UDP = "shadowsocks-udp.conf"
class Data internal constructor(private val service: Interface) {
var state = STOPPED
var state = State.Stopped
var processes: GuardedProcessPool? = null
var proxy: ProxyInstance? = null
var udpFallback: ProxyInstance? = null
......@@ -81,7 +85,7 @@ object BaseService {
val binder = Binder(this)
var connectingJob: Job? = null
fun changeState(s: Int, msg: String? = null) {
fun changeState(s: State, msg: String? = null) {
if (state == s && msg == null) return
binder.stateChanged(s, msg)
state = s
......@@ -102,7 +106,7 @@ object BaseService {
private val bandwidthListeners = mutableMapOf<IBinder, Long>() // the binder is the real identifier
private val handler = Handler()
override fun getState(): Int = data!!.state
override fun getState(): Int = data!!.state.ordinal
override fun getProfileName(): String = data!!.proxy?.profile?.name ?: "Idle"
override fun registerCallback(cb: IShadowsocksServiceCallback) {
......@@ -124,12 +128,12 @@ object BaseService {
handler.postDelayed(this::onTimeout, bandwidthListeners.values.min() ?: return)
}
private fun onTimeout() {
val proxies = listOfNotNull(data!!.proxy, data!!.udpFallback)
val proxies = listOfNotNull(data?.proxy, data?.udpFallback)
val stats = proxies
.map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) }
.filter { it.second != null }
.map { Triple(it.first, it.second!!.first, it.second!!.second) }
if (stats.any { it.third } && state == CONNECTED && bandwidthListeners.isNotEmpty()) {
if (stats.any { it.third } && data!!.state == State.Connected && bandwidthListeners.isNotEmpty()) {
val sum = stats.fold(TrafficStats()) { a, b -> a + b.second }
broadcast { item ->
if (bandwidthListeners.contains(item.asBinder())) {
......@@ -145,7 +149,7 @@ object BaseService {
val wasEmpty = bandwidthListeners.isEmpty()
if (bandwidthListeners.put(cb.asBinder(), timeout) == null) {
if (wasEmpty) registerTimeout()
if (state != CONNECTED) return
if (data!!.state != State.Connected) return
var sum = TrafficStats()
val proxy = data!!.proxy ?: return
proxy.trafficMonitor?.out.also { stats ->
......@@ -177,9 +181,9 @@ object BaseService {
callbacks.unregister(cb)
}
fun stateChanged(s: Int, msg: String?) {
fun stateChanged(s: State, msg: String?) {
val profileName = profileName
broadcast { it.stateChanged(s, profileName, msg) }
broadcast { it.stateChanged(s.ordinal, profileName, msg) }
}
fun trafficPersisted(ids: List<Long>) {
......@@ -211,9 +215,9 @@ object BaseService {
return
}
val s = data.state
when (s) {
STOPPED -> startRunner()
CONNECTED -> stopRunner(true)
when {
s == State.Stopped -> startRunner()
s.canStop -> stopRunner(true)
else -> Crashlytics.log(Log.WARN, tag, "Illegal state when invoking use: $s")
}
}
......@@ -249,11 +253,12 @@ object BaseService {
}
fun stopRunner(restart: Boolean = false, msg: String? = null) {
if (data.state == STOPPING) return
if (data.state == State.Stopping) return
// channge the state
data.changeState(STOPPING)
data.changeState(State.Stopping)
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
Core.analytics.logEvent("stop", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag)))
data.connectingJob?.cancelAndJoin() // ensure stop connecting first
this@Interface as Service
// we use a coroutineScope here to allow clean-up in parallel
coroutineScope {
......@@ -278,7 +283,7 @@ object BaseService {
}
// change the state
data.changeState(STOPPED, msg)
data.changeState(State.Stopped, msg)
// stop the service if nothing has bound to it
if (restart) startRunner() else stopSelf()
......@@ -291,7 +296,7 @@ object BaseService {
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val data = data
if (data.state != STOPPED) return Service.START_NOT_STICKY
if (data.state != State.Stopped) return Service.START_NOT_STICKY
val profilePair = Core.currentProfile
this as Context
if (profilePair == null) {
......@@ -318,7 +323,7 @@ object BaseService {
data.notification = createNotification(profile.formattedName)
Core.analytics.logEvent("start", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag)))
data.changeState(CONNECTING)
data.changeState(State.Connecting)
data.connectingJob = GlobalScope.launch(Dispatchers.Main) {
try {
Executable.killAll() // clean up old processes
......@@ -328,8 +333,7 @@ object BaseService {
data.processes = GuardedProcessPool {
printLog(it)
data.connectingJob?.cancelAndJoin()
stopRunner(false, it.localizedMessage)
stopRunner(false, it.readableMessage)
}
startProcesses()
......@@ -337,16 +341,18 @@ object BaseService {
data.udpFallback?.scheduleUpdate()
RemoteConfig.fetch()
data.changeState(CONNECTED)
data.changeState(State.Connected)
} catch (_: CancellationException) {
// if the job was cancelled, it is canceller's responsibility to call stopRunner
} catch (_: UnknownHostException) {
stopRunner(false, getString(R.string.invalid_server))
} catch (exc: Throwable) {
if (exc !is PluginManager.PluginNotFoundException && exc !is VpnService.NullConnectionException) {
if (exc !is PluginManager.PluginNotFoundException &&
exc !is BindException &&
exc !is VpnService.NullConnectionException) {
printLog(exc)
}
stopRunner(false, "${getString(R.string.service_failed)}: ${exc.localizedMessage}")
stopRunner(false, "${getString(R.string.service_failed)}: ${exc.readableMessage}")
} finally {
data.connectingJob = null
}
......
......@@ -26,7 +26,6 @@ import android.system.OsConstants
import android.text.TextUtils
import android.util.Log
import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.Core.app
import java.io.File
import java.io.FileNotFoundException
......@@ -40,11 +39,11 @@ object Executable {
fun killAll() {
for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }) {
val exe = File(try {
File(process, "cmdline").readText()
} catch (ignore: FileNotFoundException) {
File(process, "cmdline").inputStream().bufferedReader().readText()
} catch (_: FileNotFoundException) {
continue
}.split(Character.MIN_VALUE, limit = 2).first())
if (exe.parent == app.applicationInfo.nativeLibraryDir && EXECUTABLES.contains(exe.name)) try {
if (EXECUTABLES.contains(exe.name)) try {
Os.kill(process.name.toInt(), OsConstants.SIGKILL)
} catch (e: ErrnoException) {
if (e.errno != OsConstants.ESRCH) {
......
......@@ -61,7 +61,7 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
conn.doOutput = true
val proxies = try {
withTimeoutOrNull(30_000) {
withTimeoutOrNull(10_000) {
withContext(Dispatchers.IO) {
conn.outputStream.bufferedWriter().use {
it.write("sig=" + Base64.encodeToString(mdg.digest(), Base64.DEFAULT))
......@@ -80,7 +80,9 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
profile.method = proxy[3].trim()
}
if (route == Acl.CUSTOM_RULES) Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10))
if (route == Acl.CUSTOM_RULES) withContext(Dispatchers.IO) {
Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10, service::openConnection))
}
// it's hard to resolve DNS on a specific interface so we'll do it here
if (profile.host.parseNumericAddress() == null) profile.host = withTimeoutOrNull(10_000) {
......
......@@ -94,7 +94,7 @@ class ServiceNotification(private val service: BaseService.Interface, profileNam
}
private fun update(action: String?, forceShow: Boolean = false) {
if (forceShow || service.data.state == BaseService.CONNECTED) when (action) {
if (forceShow || service.data.state == BaseService.State.Connected) when (action) {
Intent.ACTION_SCREEN_OFF -> {
setVisible(false, forceShow)
unregisterCallback() // unregister callback to save battery
......
......@@ -21,6 +21,7 @@
package com.github.shadowsocks.database
import android.net.Uri
import android.os.Parcelable
import android.util.Base64
import android.util.Log
import android.util.LongSparseArray
......@@ -32,6 +33,7 @@ import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.asIterable
import com.github.shadowsocks.utils.parsePort
import kotlinx.android.parcel.Parcelize
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
......@@ -41,7 +43,31 @@ import java.net.URISyntaxException
import java.util.*
@Entity
class Profile : Serializable {
@Parcelize
data class Profile(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
var name: String? = "",
var host: String = "198.199.101.152",
var remotePort: Int = 8388,
var password: String = "u1rRWTssNv0p",
var method: String = "aes-256-cfb",
var route: String = "all",
var remoteDns: String = "8.8.8.8",
var proxyApps: Boolean = false,
var bypass: Boolean = false,
var udpdns: Boolean = false,
var ipv6: Boolean = true,
var individual: String = "",
var tx: Long = 0,
var rx: Long = 0,
var userOrder: Long = 0,
var plugin: String? = null,
var udpFallback: Long? = null,
@Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false
) : Parcelable, Serializable {
companion object {
private const val TAG = "ShadowParser"
private const val serialVersionUID = 0L
......@@ -200,29 +226,6 @@ class Profile : Serializable {
fun deleteAll(): Int
}
@PrimaryKey(autoGenerate = true)
var id: Long = 0
var name: String? = ""
var host: String = "198.199.101.152"
var remotePort: Int = 8388
var password: String = "u1rRWTssNv0p"
var method: String = "aes-256-cfb"
var route: String = "all"
var remoteDns: String = "8.8.8.8"
var proxyApps: Boolean = false
var bypass: Boolean = false
var udpdns: Boolean = false
var ipv6: Boolean = true
var individual: String = ""
var tx: Long = 0
var rx: Long = 0
var userOrder: Long = 0
var plugin: String? = null
var udpFallback: Long? = null
@Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false
val formattedAddress get() = (if (host.contains(":")) "[%s]:%d" else "%s:%d").format(host, remotePort)
val formattedName get() = if (name.isNullOrEmpty()) formattedAddress else name!!
......
......@@ -21,11 +21,14 @@
package com.github.shadowsocks.database
import android.database.sqlite.SQLiteCantOpenDatabaseException
import android.util.LongSparseArray
import com.github.shadowsocks.Core
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.printLog
import org.json.JSONArray
import java.io.IOException
import java.io.InputStream
import java.sql.SQLException
/**
......@@ -36,6 +39,7 @@ object ProfileManager {
interface Listener {
fun onAdd(profile: Profile)
fun onRemove(profileId: Long)
fun onCleared()
}
var listener: Listener? = null
......@@ -48,6 +52,36 @@ object ProfileManager {
return profile
}
fun createProfilesFromJson(jsons: Sequence<InputStream>, replace: Boolean = false) {
val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
val feature = if (replace) {
profiles?.values?.singleOrNull { it.id == DataStore.profileId }
} else Core.currentProfile?.first
val lazyClear = lazy { clear() }
var result: Exception? = null
for (json in jsons) try {
Profile.parseJson(json.bufferedReader().readText(), feature) {
if (replace) {
lazyClear.value
// if two profiles has the same address, treat them as the same profile and copy stats over
profiles?.get(it.formattedAddress)?.apply {
it.tx = tx
it.rx = rx
}
}
createProfile(it)
}
} catch (e: Exception) {
if (result == null) result = e else result.addSuppressed(e)
}
if (result != null) throw result
}
fun serializeToJson(profiles: List<Profile>? = getAllProfiles()): JSONArray? {
if (profiles == null) return null
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
return JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray())
}
/**
* Note: It's caller's responsibility to update DirectBoot profile if necessary.
*/
......@@ -78,6 +112,7 @@ object ProfileManager {
fun clear() = PrivateDatabase.profileDao.deleteAll().also {
// listener is not called since this won't be used in mobile submodule
DirectBoot.clean()
listener?.onCleared()
}
@Throws(IOException::class)
......
......@@ -20,6 +20,7 @@
package com.github.shadowsocks.net
import android.os.Build
import com.github.shadowsocks.utils.printLog
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
......@@ -65,23 +66,31 @@ class ChannelMonitor : Thread("ChannelMonitor") {
start()
}
/**
* Prevent NetworkOnMainThreadException because people enable strict mode for no reasons.
*/
private suspend fun WritableByteChannel.writeCompat(src: ByteBuffer) =
if (Build.VERSION.SDK_INT <= 23) withContext(Dispatchers.Default) { write(src) } else write(src)
suspend fun register(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit): SelectionKey {
val registration = Registration(channel, ops, block)
pendingRegistrations.send(registration)
ByteBuffer.allocateDirect(1).also { junk ->
loop@ while (running) when (registrationPipe.sink().write(junk)) {
loop@ while (running) when (registrationPipe.sink().writeCompat(junk)) {
0 -> kotlinx.coroutines.yield()
1 -> break@loop
else -> throw IOException("Failed to register in the channel")
}
}
if (!running) throw ClosedChannelException()
if (!running) throw CancellationException()
return registration.result.await()
}
suspend fun wait(channel: SelectableChannel, ops: Int) = CompletableDeferred<SelectionKey>().run {
register(channel, ops) {
if (it.isValid) it.interestOps(0) // stop listening
if (it.isValid) try {
it.interestOps(0) // stop listening
} catch (_: CancelledKeyException) { }
complete(it)
}
await()
......
......@@ -68,7 +68,7 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
private fun prepareDnsResponse(request: Message) = Message(request.header.id).apply {
header.setFlag(Flags.QR.toInt()) // this is a response
if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt())
addRecord(request.question, Section.QUESTION)
request.question?.also { addRecord(it, Section.QUESTION) }
}
}
private val monitor = ChannelMonitor()
......@@ -99,30 +99,30 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
printLog(e)
return forward(packet)
}
return coroutineScope {
return supervisorScope {
val remote = async { withTimeout(TIMEOUT) { forward(packet) } }
try {
if (forwardOnly || request.header.opcode != Opcode.QUERY) return@coroutineScope remote.await()
if (forwardOnly || request.header.opcode != Opcode.QUERY) return@supervisorScope remote.await()
val question = request.question
if (question?.type != Type.A) return@coroutineScope remote.await()
if (question?.type != Type.A) return@supervisorScope remote.await()
val host = question.name.toString(true)
if (remoteDomainMatcher?.containsMatchIn(host) == true) return@coroutineScope remote.await()
if (remoteDomainMatcher?.containsMatchIn(host) == true) return@supervisorScope remote.await()
val localResults = try {
withTimeout(TIMEOUT) { GlobalScope.async(Dispatchers.IO) { localResolver(host) }.await() }
} catch (_: TimeoutCancellationException) {
Crashlytics.log(Log.WARN, TAG, "Local resolving timed out, falling back to remote resolving")
return@coroutineScope remote.await()
return@supervisorScope remote.await()
} catch (_: UnknownHostException) {
return@coroutineScope remote.await()
return@supervisorScope remote.await()
}
if (localResults.isEmpty()) return@coroutineScope remote.await()
if (localResults.isEmpty()) return@supervisorScope remote.await()
if (localIpMatcher.isEmpty() || localIpMatcher.any { subnet -> localResults.any(subnet::matches) }) {
remote.cancel()
ByteBuffer.wrap(prepareDnsResponse(request).apply {
header.setFlag(Flags.RA.toInt()) // recursion available
for (address in localResults) addRecord(when (address) {
is Inet4Address -> ARecord(request.question.name, DClass.IN, TTL, address)
is Inet6Address -> AAAARecord(request.question.name, DClass.IN, TTL, address)
is Inet4Address -> ARecord(question.name, DClass.IN, TTL, address)
is Inet6Address -> AAAARecord(question.name, DClass.IN, TTL, address)
else -> throw IllegalStateException("Unsupported address $address")
}, Section.ANSWER)
}.toWire())
......@@ -159,7 +159,7 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
check(channel.send(remoteDns.udpWrap(packet), proxy) > 0)
monitor.wait(channel, SelectionKey.OP_READ)
val result = remoteDns.udpReceiveBuffer(UDP_PACKET_SIZE)
check(channel.receive(result) == proxy)
while (channel.receive(result) != proxy) result.clear()
result.flip()
remoteDns.udpUnwrap(result)
result
......
......@@ -65,7 +65,7 @@ abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name
open fun shutdown(scope: CoroutineScope) {
running = false
localSocket.fileDescriptor.apply {
localSocket.fileDescriptor?.apply {
// see also: https://issuetracker.google.com/issues/36945762#comment15
if (valid()) try {
Os.shutdown(this, OsConstants.SHUT_RDWR)
......
......@@ -20,6 +20,7 @@
package com.github.shadowsocks.net
import com.github.shadowsocks.utils.readableMessage
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
......@@ -57,7 +58,7 @@ object TcpFastOpen {
ProcessBuilder("su", "-c", "echo 3 > $PATH").redirectErrorStream(true).start()
.inputStream.bufferedReader().readText()
} catch (e: IOException) {
e.localizedMessage
e.readableMessage
}
}
fun enableTimeout() = runBlocking { withTimeoutOrNull(1000) { enable() } }
......
......@@ -36,5 +36,5 @@ class DeviceStorageApp(context: Context) : Application() {
* Thou shalt not get the REAL underlying application context which would no longer be operating under device
* protected storage.
*/
override fun getApplicationContext(): Context = this
override fun getApplicationContext() = this
}
......@@ -42,6 +42,8 @@ import kotlinx.coroutines.launch
import java.net.HttpURLConnection
import java.net.InetAddress
val Throwable.readableMessage get() = localizedMessage ?: javaClass.name
private val parseNumericAddress by lazy {
InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
isAccessible = true
......
Subproject commit 7ad2df961505ecddb44984ce34ea86874cd2ecc4
Subproject commit b430124ed82973d4edf345dfadbe556f93377484
......@@ -97,6 +97,7 @@
<string name="action_export">Export to Clipboard</string>
<string name="action_import">Import from Clipboard</string>
<string name="action_import_file">Import from file…</string>
<string name="action_replace_file">Replace from file…</string>
<string name="action_export_msg">Successfully export!</string>
<string name="action_export_err">Failed to export.</string>
<string name="action_import_msg">Successfully import!</string>
......
......@@ -5,6 +5,7 @@ import java.util.regex.Pattern
apply plugin: 'com.android.application'
apply plugin: 'io.fabric'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
def getCurrentFlavor() {
String task = getGradle().getStartParameter().getTaskRequests().toString()
......@@ -49,6 +50,10 @@ android {
new File(project(':core').buildDir, "intermediates/bundles/${getCurrentFlavor()}/jni")
}
androidExtensions {
experimental = true
}
dependencies {
implementation project(':core')
implementation 'androidx.browser:browser:1.0.0'
......
......@@ -88,8 +88,8 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() {
portTransproxy.isEnabled = enabledTransproxy
true
}
val listener: (Int) -> Unit = {
if (it == BaseService.STOPPED) {
val listener: (BaseService.State) -> Unit = {
if (it == BaseService.State.Stopped) {
tfo.isEnabled = true
serviceMode.isEnabled = true
portProxy.isEnabled = true
......
......@@ -23,6 +23,7 @@ package com.github.shadowsocks
import android.app.Activity
import android.app.backup.BackupManager
import android.content.ActivityNotFoundException
import android.content.DialogInterface
import android.content.Intent
import android.net.VpnService
import android.nfc.NdefMessage
......@@ -30,6 +31,7 @@ import android.nfc.NfcAdapter
import android.os.Bundle
import android.os.DeadObjectException
import android.os.Handler
import android.os.Parcelable
import android.util.Log
import android.view.KeyCharacterMap
import android.view.KeyEvent
......@@ -51,6 +53,8 @@ import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.AlertDialogFragment
import com.github.shadowsocks.plugin.Empty
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener
import com.github.shadowsocks.utils.Key
......@@ -58,6 +62,7 @@ import com.github.shadowsocks.widget.ServiceButton
import com.github.shadowsocks.widget.StatsBar
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.parcel.Parcelize
class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPreferenceDataStoreChangeListener,
NavigationView.OnNavigationItemSelectedListener {
......@@ -65,7 +70,18 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
private const val TAG = "ShadowsocksMainActivity"
private const val REQUEST_CONNECT = 1
var stateListener: ((Int) -> Unit)? = null
var stateListener: ((BaseService.State) -> Unit)? = null
}
@Parcelize
data class ProfilesArg(val profiles: List<Profile>) : Parcelable
class ImportProfilesDialogFragment : AlertDialogFragment<ProfilesArg, Empty>() {
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setTitle(R.string.add_profile_dialog)
setPositiveButton(R.string.yes) { _, _ -> arg.profiles.forEach { ProfileManager.createProfile(it) } }
setNegativeButton(R.string.no, null)
setMessage(arg.profiles.joinToString("\n"))
}
}
// UI
......@@ -91,12 +107,12 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
}
// service
var state = BaseService.IDLE
override fun stateChanged(state: Int, profileName: String?, msg: String?) = changeState(state, msg, true)
var state = BaseService.State.Idle
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) = changeState(state, msg, true)
override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
if (profileId == 0L) this@MainActivity.stats.updateTraffic(
stats.txRate, stats.rxRate, stats.txTotal, stats.rxTotal)
if (state != BaseService.STOPPING) {
if (state != BaseService.State.Stopping) {
(supportFragmentManager.findFragmentById(R.id.fragment_holder) as? ToolbarFragment)
?.onTrafficUpdated(profileId, stats)
}
......@@ -105,7 +121,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
ProfilesFragment.instance?.onTrafficPersisted(profileId)
}
private fun changeState(state: Int, msg: String? = null, animate: Boolean = false) {
private fun changeState(state: BaseService.State, msg: String? = null, animate: Boolean = false) {
fab.changeState(state, animate)
stats.changeState(state)
if (msg != null) snackbar(getString(R.string.vpn_error, msg)).show()
......@@ -115,7 +131,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
}
private fun toggle() = when {
state == BaseService.CONNECTED -> Core.stopService()
state.canStop -> Core.stopService()
DataStore.serviceMode == Key.modeVpn -> {
val intent = VpnService.prepare(this)
if (intent != null) startActivityForResult(intent, REQUEST_CONNECT)
......@@ -127,11 +143,11 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
private val handler = Handler()
private val connection = ShadowsocksConnection(handler, true)
override fun onServiceConnected(service: IShadowsocksService) = changeState(try {
service.state
BaseService.State.values()[service.state]
} catch (_: DeadObjectException) {
BaseService.IDLE
BaseService.State.Idle
})
override fun onServiceDisconnected() = changeState(BaseService.IDLE)
override fun onServiceDisconnected() = changeState(BaseService.State.Idle)
override fun onBinderDied() {
connection.disconnect(this)
connection.connect(this, this)
......@@ -152,7 +168,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
super.onCreate(savedInstanceState)
setContentView(R.layout.layout_main)
stats = findViewById(R.id.stats)
stats.setOnClickListener { if (state == BaseService.CONNECTED) stats.testConnection() }
stats.setOnClickListener { if (state == BaseService.State.Connected) stats.testConnection() }
drawer = findViewById(R.id.drawer)
navigation = findViewById(R.id.navigation)
navigation.setNavigationItemSelectedListener(this)
......@@ -164,7 +180,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
fab = findViewById(R.id.fab)
fab.setOnClickListener { toggle() }
changeState(BaseService.IDLE) // reset everything to init state
changeState(BaseService.State.Idle) // reset everything to init state
connection.connect(this, this)
DataStore.publicStore.registerChangeListener(this)
......@@ -188,17 +204,8 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
}
if (sharedStr.isNullOrEmpty()) return
val profiles = Profile.findAllUrls(sharedStr, Core.currentProfile?.first).toList()
if (profiles.isEmpty()) {
snackbar().setText(R.string.profile_invalid_input).show()
return
}
AlertDialog.Builder(this)
.setTitle(R.string.add_profile_dialog)
.setPositiveButton(R.string.yes) { _, _ -> profiles.forEach { ProfileManager.createProfile(it) } }
.setNegativeButton(R.string.no, null)
.setMessage(profiles.joinToString("\n"))
.create()
.show()
if (profiles.isEmpty()) snackbar().setText(R.string.profile_invalid_input).show()
else ImportProfilesDialogFragment().withArg(ProfilesArg(profiles)).show(supportFragmentManager, null)
}
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) {
......
......@@ -21,12 +21,15 @@
package com.github.shadowsocks
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.github.shadowsocks.plugin.AlertDialogFragment
import com.github.shadowsocks.plugin.Empty
import com.github.shadowsocks.plugin.PluginContract
import com.github.shadowsocks.preference.DataStore
......@@ -35,6 +38,15 @@ class ProfileConfigActivity : AppCompatActivity() {
const val REQUEST_CODE_PLUGIN_HELP = 1
}
class UnsavedChangesDialogFragment : AlertDialogFragment<Empty, Empty>() {
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setTitle(R.string.unsaved_changes_prompt)
setPositiveButton(R.string.yes, listener)
setNegativeButton(R.string.no, listener)
setNeutralButton(android.R.string.cancel, null)
}
}
private val child by lazy { supportFragmentManager.findFragmentById(R.id.content) as ProfileConfigFragment }
override fun onCreate(savedInstanceState: Bundle?) {
......@@ -59,13 +71,8 @@ class ProfileConfigActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item)
override fun onBackPressed() {
if (DataStore.dirty) AlertDialog.Builder(this)
.setTitle(R.string.unsaved_changes_prompt)
.setPositiveButton(R.string.yes) { _, _ -> child.saveAndExit() }
.setNegativeButton(R.string.no) { _, _ -> finish() }
.setNeutralButton(android.R.string.cancel, null)
.create()
.show() else super.onBackPressed()
if (DataStore.dirty) UnsavedChangesDialogFragment().show(child, ProfileConfigFragment.REQUEST_UNSAVED_CHANGES)
else super.onBackPressed()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
......
......@@ -22,23 +22,24 @@ package com.github.shadowsocks
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.preference.*
import com.github.shadowsocks.Core.app
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.PluginConfiguration
import com.github.shadowsocks.plugin.PluginContract
import com.github.shadowsocks.plugin.PluginManager
import com.github.shadowsocks.plugin.PluginOptions
import com.github.shadowsocks.plugin.*
import com.github.shadowsocks.preference.*
import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.readableMessage
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.parcel.Parcelize
class ProfileConfigFragment : PreferenceFragmentCompat(),
Preference.OnPreferenceChangeListener, OnPreferenceDataStoreChangeListener {
......@@ -46,6 +47,20 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
override fun provideSummary(preference: EditTextPreference?) = "\u2022".repeat(preference?.text?.length ?: 0)
private const val REQUEST_CODE_PLUGIN_CONFIGURE = 1
const val REQUEST_UNSAVED_CHANGES = 2
}
@Parcelize
data class ProfileIdArg(val profileId: Long) : Parcelable
class DeleteConfirmationDialogFragment : AlertDialogFragment<ProfileIdArg, Empty>() {
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setTitle(R.string.delete_confirm_prompt)
setPositiveButton(R.string.yes) { _, _ ->
ProfileManager.delProfile(arg.profileId)
requireActivity().finish()
}
setNegativeButton(R.string.no, null)
}
}
private var profileId = -1L
......@@ -114,7 +129,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
}.show(fragmentManager ?: return, Key.pluginConfigure)
}
fun saveAndExit() {
private fun saveAndExit() {
val profile = ProfileManager.getProfile(profileId) ?: Profile()
profile.id = profileId
profile.deserialize()
......@@ -140,7 +155,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
DataStore.dirty = true
true
} catch (exc: RuntimeException) {
Snackbar.make(view!!, exc.localizedMessage, Snackbar.LENGTH_LONG).show()
Snackbar.make(view!!, exc.readableMessage, Snackbar.LENGTH_LONG).show()
false
}
......@@ -167,28 +182,26 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_PLUGIN_CONFIGURE) when (resultCode) {
Activity.RESULT_OK -> {
val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS)
pluginConfigure.text = options
onPreferenceChange(null, options)
when (requestCode) {
REQUEST_CODE_PLUGIN_CONFIGURE -> when (resultCode) {
Activity.RESULT_OK -> {
val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS)
pluginConfigure.text = options
onPreferenceChange(null, options)
}
PluginContract.RESULT_FALLBACK -> showPluginEditor()
}
REQUEST_UNSAVED_CHANGES -> when (resultCode) {
DialogInterface.BUTTON_POSITIVE -> saveAndExit()
DialogInterface.BUTTON_NEGATIVE -> requireActivity().finish()
}
PluginContract.RESULT_FALLBACK -> showPluginEditor()
} else super.onActivityResult(requestCode, resultCode, data)
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_delete -> {
val activity = requireActivity()
AlertDialog.Builder(activity)
.setTitle(R.string.delete_confirm_prompt)
.setPositiveButton(R.string.yes) { _, _ ->
ProfileManager.delProfile(profileId)
activity.finish()
}
.setNegativeButton(R.string.no, null)
.create()
.show()
DeleteConfirmationDialogFragment().withArg(ProfileIdArg(profileId)).show(this)
true
}
R.id.action_apply -> {
......
......@@ -49,12 +49,12 @@ import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.datas
import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.readableMessage
import com.github.shadowsocks.widget.UndoSnackbarManager
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView
import net.glxn.qrgen.android.QRCode
import org.json.JSONArray
class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
companion object {
......@@ -65,21 +65,16 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
private const val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL"
private const val REQUEST_IMPORT_PROFILES = 1
private const val REQUEST_REPLACE_PROFILES = 3
private const val REQUEST_EXPORT_PROFILES = 2
}
/**
* Is ProfilesFragment editable at all.
*/
private val isEnabled get() = when ((activity as MainActivity).state) {
BaseService.CONNECTED, BaseService.STOPPED -> true
else -> false
}
private fun isProfileEditable(id: Long) = when ((activity as MainActivity).state) {
BaseService.CONNECTED -> id !in Core.activeProfileIds
BaseService.STOPPED -> true
else -> false
}
private val isEnabled get() = (activity as MainActivity).state.let { it.canStop || it == BaseService.State.Stopped }
private fun isProfileEditable(id: Long) =
(activity as MainActivity).state == BaseService.State.Stopped || id !in Core.activeProfileIds
@SuppressLint("ValidFragment")
class QRCodeDialog() : DialogFragment() {
......@@ -175,21 +170,20 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
var adView = adView
if (item.host == "198.199.101.152") {
if (adView == null) {
val params = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT)
val params = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
AdSize.SMART_BANNER.getHeightInPixels(context))
params.gravity = Gravity.CENTER_HORIZONTAL
adView = AdView(context)
adView.layoutParams = params
adView.adUnitId = "ca-app-pub-9097031975646651/7760346322"
adView.adSize = AdSize.FLUID
val padding = context.resources.getDimensionPixelOffset(R.dimen.profile_padding)
adView.setPadding(padding, 0, 0, padding)
adView.adSize = AdSize.SMART_BANNER
itemView.findViewById<LinearLayout>(R.id.content).addView(adView)
// Load Ad
val adBuilder = AdRequest.Builder()
adBuilder.addTestDevice("B08FC1764A7B250E91EA9D0D5EBEB208")
adBuilder.addTestDevice("7509D18EB8AF82F915874FEF53877A64")
adView.loadAd(adBuilder.build())
this.adView = adView
} else adView.visibility = View.VISIBLE
......@@ -203,7 +197,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
Core.switchProfile(item.id)
profilesAdapter.refreshId(old)
itemView.isSelected = true
if (activity.state == BaseService.CONNECTED) Core.reloadService()
if (activity.state.canStop) Core.reloadService()
}
}
......@@ -296,6 +290,11 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
notifyItemRemoved(index)
if (profileId == DataStore.profileId) DataStore.profileId = 0 // switch to null profile
}
override fun onCleared() {
profiles.clear()
notifyDataSetChanged()
}
}
private var selectedItem: ProfileViewHolder? = null
......@@ -383,12 +382,20 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}
R.id.action_import_file -> {
startFilesForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json"
type = "application/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*"))
}, REQUEST_IMPORT_PROFILES)
true
}
R.id.action_replace_file -> {
startFilesForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "application/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*"))
}, REQUEST_REPLACE_PROFILES)
true
}
R.id.action_manual_settings -> {
startConfig(ProfileManager.createProfile(
Profile().also { Core.currentProfile?.first?.copyFeatureSettingsTo(it) }))
......@@ -404,7 +411,6 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}
R.id.action_export_file -> {
startFilesForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json"
putExtra(Intent.EXTRA_TITLE, "profiles.json") // optional title that can be edited
}, REQUEST_EXPORT_PROFILES)
......@@ -414,9 +420,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}
}
private fun startFilesForResult(intent: Intent?, requestCode: Int) {
private fun startFilesForResult(intent: Intent, requestCode: Int) {
try {
startActivityForResult(intent, requestCode)
startActivityForResult(intent.addCategory(Intent.CATEGORY_OPENABLE), requestCode)
return
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { }
(activity as MainActivity).snackbar(getString(R.string.file_manager_missing)).show()
......@@ -426,31 +432,34 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data)
else when (requestCode) {
REQUEST_IMPORT_PROFILES -> {
val feature = Core.currentProfile?.first
var success = false
val activity = activity as MainActivity
for (uri in data!!.datas) try {
Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(),
feature) {
ProfileManager.createProfile(it)
success = true
}
try {
ProfileManager.createProfilesFromJson(data!!.datas.asSequence().map {
activity.contentResolver.openInputStream(it)
})
} catch (e: Exception) {
printLog(e)
activity.snackbar(e.readableMessage).show()
}
}
REQUEST_REPLACE_PROFILES -> {
val activity = activity as MainActivity
try {
ProfileManager.createProfilesFromJson(data!!.datas.asSequence().map {
activity.contentResolver.openInputStream(it)
}, true)
} catch (e: Exception) {
activity.snackbar(e.readableMessage).show()
}
activity.snackbar().setText(if (success) R.string.action_import_msg else R.string.action_import_err)
.show()
}
REQUEST_EXPORT_PROFILES -> {
val profiles = ProfileManager.getAllProfiles()
val profiles = ProfileManager.serializeToJson()
if (profiles != null) try {
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
requireContext().contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use {
it.write(JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray()).toString(2))
it.write(profiles.toString(2))
}
} catch (e: Exception) {
printLog(e)
(activity as MainActivity).snackbar(e.localizedMessage).show()
(activity as MainActivity).snackbar(e.readableMessage).show()
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
......
......@@ -55,14 +55,15 @@ class QuickToggleShortcut : Activity(), ShadowsocksConnection.Callback {
}
override fun onServiceConnected(service: IShadowsocksService) {
when (service.state) {
BaseService.STOPPED -> Core.startService()
BaseService.CONNECTED -> Core.stopService()
val state = BaseService.State.values()[service.state]
when {
state.canStop -> Core.stopService()
state == BaseService.State.Stopped -> Core.startService()
}
finish()
}
override fun stateChanged(state: Int, profileName: String?, msg: String?) { }
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { }
override fun onDestroy() {
connection.disconnect(this)
......
......@@ -41,9 +41,9 @@ class TileService : BaseTileService(), ShadowsocksConnection.Callback {
private var tapPending = false
private val connection = ShadowsocksConnection()
override fun stateChanged(state: Int, profileName: String?, msg: String?) = updateTile(state) { profileName }
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) = updateTile(state) { profileName }
override fun onServiceConnected(service: IShadowsocksService) {
updateTile(service.state) { service.profileName }
updateTile(BaseService.State.values()[service.state]) { service.profileName }
if (tapPending) {
tapPending = false
onClick()
......@@ -63,23 +63,28 @@ class TileService : BaseTileService(), ShadowsocksConnection.Callback {
if (isLocked && !DataStore.canToggleLocked) unlockAndRun(this::toggle) else toggle()
}
private fun updateTile(serviceState: Int, profileName: () -> String?) {
private fun updateTile(serviceState: BaseService.State, profileName: () -> String?) {
qsTile?.apply {
label = null
when (serviceState) {
BaseService.STOPPED -> {
icon = iconIdle
state = Tile.STATE_INACTIVE
BaseService.State.Idle -> throw IllegalStateException("serviceState")
BaseService.State.Connecting -> {
icon = iconBusy
state = Tile.STATE_ACTIVE
}
BaseService.CONNECTED -> {
BaseService.State.Connected -> {
icon = iconConnected
if (!keyguard.isDeviceLocked) label = profileName()
state = Tile.STATE_ACTIVE
}
else -> {
BaseService.State.Stopping -> {
icon = iconBusy
state = Tile.STATE_UNAVAILABLE
}
BaseService.State.Stopped -> {
icon = iconIdle
state = Tile.STATE_INACTIVE
}
}
label = label ?: getString(R.string.app_name)
updateTile()
......@@ -88,9 +93,11 @@ class TileService : BaseTileService(), ShadowsocksConnection.Callback {
private fun toggle() {
val service = connection.service
if (service == null) tapPending = true else when (service.state) {
BaseService.STOPPED -> Core.startService()
BaseService.CONNECTED -> Core.stopService()
if (service == null) tapPending = true else BaseService.State.values()[service.state].let { state ->
when {
state.canStop -> Core.stopService()
state == BaseService.State.Stopped -> Core.startService()
}
}
}
}
......@@ -68,14 +68,14 @@ class ServiceButton @JvmOverloads constructor(context: Context, attrs: Attribute
return drawableState
}
fun changeState(state: Int, animate: Boolean) {
fun changeState(state: BaseService.State, animate: Boolean) {
when (state) {
BaseService.CONNECTING -> changeState(iconConnecting, animate)
BaseService.CONNECTED -> changeState(iconConnected, animate)
BaseService.STOPPING -> changeState(iconStopping, animate)
BaseService.State.Connecting -> changeState(iconConnecting, animate)
BaseService.State.Connected -> changeState(iconConnected, animate)
BaseService.State.Stopping -> changeState(iconStopping, animate)
else -> changeState(iconStopped, animate)
}
if (state == BaseService.CONNECTED) {
if (state == BaseService.State.Connected) {
checked = true
TooltipCompat.setTooltipText(this, context.getString(R.string.stop))
} else {
......@@ -83,7 +83,7 @@ class ServiceButton @JvmOverloads constructor(context: Context, attrs: Attribute
TooltipCompat.setTooltipText(this, context.getString(R.string.connect))
}
refreshDrawableState()
isEnabled = state == BaseService.CONNECTED || state == BaseService.STOPPED
isEnabled = state.canStop || state == BaseService.State.Stopped
}
private fun counters(a: AnimatedVectorDrawableCompat, b: AnimatedVectorDrawableCompat): Boolean =
......
......@@ -65,15 +65,15 @@ class StatsBar @JvmOverloads constructor(context: Context, attrs: AttributeSet?
super.setOnClickListener(l)
}
fun changeState(state: Int) {
fun changeState(state: BaseService.State) {
val activity = context as MainActivity
if (state != BaseService.CONNECTED) {
if (state != BaseService.State.Connected) {
updateTraffic(0, 0, 0, 0)
tester.status.removeObservers(activity)
if (state != BaseService.IDLE) tester.invalidate()
if (state != BaseService.State.Idle) tester.invalidate()
statusText.setText(when (state) {
BaseService.CONNECTING -> R.string.connecting
BaseService.STOPPING -> R.string.stopping
BaseService.State.Connecting -> R.string.connecting
BaseService.State.Stopping -> R.string.stopping
else -> R.string.not_connected
})
} else {
......
......@@ -18,6 +18,11 @@
android:id="@+id/action_import_file"
android:alphabeticShortcut="o"
android:title="@string/action_import_file"/>
<item
android:id="@+id/action_replace_file"
android:alphabeticShortcut="o"
android:title="@string/action_replace_file"
app:alphabeticModifiers="CTRL|SHIFT"/>
<item
android:id="@+id/action_manual_settings"
android:title="@string/add_profile_methods_manual_settings"
......
* 1.2.0:
* New helper class `AlertDialogFragment` for creating `AlertDialog` that persists through configuration changes;
* Dependency update: `com.google.android.material:material:1.1.0-alpha03`.
* 1.1.0:
* Having control characters in plugin options is no longer allowed.
If this breaks your plugin, you are doing it wrong.
......
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'com.vanniktech.maven.publish'
apply from: 'gradle-mvn-push.gradle'
@SuppressWarnings("GroovyUnusedDeclaration")
def getRepositoryUsername() {
return hasProperty('NEXUS_USERNAME') ? NEXUS_USERNAME : ""
}
@SuppressWarnings("GroovyUnusedDeclaration")
def getRepositoryPassword() {
return hasProperty('NEXUS_PASSWORD') ? NEXUS_PASSWORD : ""
}
android {
buildToolsVersion rootProject.buildToolsVersion
......@@ -10,10 +20,10 @@ android {
defaultConfig {
minSdkVersion rootProject.minSdkVersion
targetSdkVersion rootProject.sdkVersion
versionCode 8
versionName "1.1.0"
versionCode Integer.parseInt(VERSION_CODE)
versionName VERSION_NAME
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "androidx.testgetRepositoryPassword().runner.AndroidJUnitRunner"
}
buildTypes {
......@@ -23,6 +33,21 @@ android {
}
}
androidExtensions {
experimental = true
}
mavenPublish {
targets {
uploadArchives {
releaseRepositoryUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
snapshotRepositoryUrl = "https://oss.sonatype.org/content/repositories/snapshots/"
repositoryUsername = getRepositoryUsername()
repositoryPassword = getRepositoryPassword()
}
}
}
dependencies {
api 'androidx.core:core-ktx:1.0.1'
api 'com.google.android.material:material:1.1.0-alpha03'
......
/*
* Copyright 2013 Chris Banes
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
apply plugin: 'maven'
apply plugin: 'signing'
@SuppressWarnings(["GroovyUnusedDeclaration", "GrMethodMayBeStatic"])
def isReleaseBuild() {
return !android.defaultConfig.versionName.contains("SNAPSHOT")
}
@SuppressWarnings("GroovyUnusedDeclaration")
def getReleaseRepositoryUrl() {
return hasProperty('RELEASE_REPOSITORY_URL') ? RELEASE_REPOSITORY_URL
: "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
}
@SuppressWarnings("GroovyUnusedDeclaration")
def getSnapshotRepositoryUrl() {
return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL
: "https://oss.sonatype.org/content/repositories/snapshots/"
}
@SuppressWarnings("GroovyUnusedDeclaration")
def getRepositoryUsername() {
return hasProperty('NEXUS_USERNAME') ? NEXUS_USERNAME : ""
}
@SuppressWarnings("GroovyUnusedDeclaration")
def getRepositoryPassword() {
return hasProperty('NEXUS_PASSWORD') ? NEXUS_PASSWORD : ""
}
afterEvaluate { project ->
uploadArchives {
repositories {
mavenDeployer {
beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) }
pom.groupId = 'com.github.shadowsocks'
pom.artifactId = 'plugin'
pom.version = android.defaultConfig.versionName
repository(url: getReleaseRepositoryUrl()) {
authentication(userName: getRepositoryUsername(), password: getRepositoryPassword())
}
snapshotRepository(url: getSnapshotRepositoryUrl()) {
authentication(userName: getRepositoryUsername(), password: getRepositoryPassword())
}
pom.project {
name 'Shadowsocks Plugin'
packaging 'aar'
description 'Shadowsocks Plugin'
url 'https://github.com/shadowsocks/shadowsocks-android'
scm {
url 'https://github.com/shadowsocks/shadowsocks-android.git'
connection 'scm:git:git://github.com/shadowsocks/shadowsocks-android.git'
developerConnection 'scm:git:git@github.com:shadowsocks/shadowsocks-android.git'
}
licenses {
license {
name 'GPLv3'
url 'https://www.gnu.org/licenses/gpl-3.0.html'
distribution 'repo'
}
}
developers {
developer {
id 'madeye'
name 'Max Lv'
url 'https://github.com/madeye'
}
developer {
id 'Mygod'
name 'Mygod Studio'
url 'https://github.com/Mygod'
}
}
}
}
}
}
signing {
required { isReleaseBuild() && gradle.taskGraph.hasTask("uploadArchives") }
sign configurations.archives
}
android.libraryVariants.all { variant ->
def javadocTask = task("generate${variant.name.capitalize()}Javadoc", type: Javadoc) {
description "Generates Javadoc for $variant.name."
source = android.sourceSets.main.java.srcDirs
classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
exclude '**/*.kt'
exclude '**/BuildConfig.java'
exclude '**/R.java'
}
def jarJavadocTask = task("jar${variant.name.capitalize()}Javadoc", type: Jar) {
description "Generate Javadoc Jar for $variant.name"
classifier = 'javadoc'
from javadocTask.destinationDir
}
jarJavadocTask.dependsOn javadocTask
artifacts.add('archives', jarJavadocTask)
def jarSourceTask = task("jar${variant.name.capitalize()}Sources", type: Jar) {
description "Generates Java Sources for $variant.name."
classifier = 'sources'
from android.sourceSets.main.java.srcDirs
}
artifacts.add('archives', jarSourceTask)
}
}
GROUP=com.github.shadowsocks
VERSION_NAME=1.2.0
VERSION_CODE=9
POM_ARTIFACT_ID=plugin
POM_NAME=Shadowsocks Plugin
POM_PACKAGING=aar
POM_DESCRIPTION=SIP003 plugin for Shadowsocks
POM_INCEPTION_YEAR=2018
POM_URL=https://github.com/shadowsocks/shadowsocks-android
POM_SCM_URL=https://github.com/shadowsocks/shadowsocks-android
POM_SCM_CONNECTION=scm:git:git://github.com/shadowsocks/shadowsocks-android.git
POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/shadowsocks/shadowsocks-android.git
POM_LICENCE_NAME=The GNU General Public License v3.0
POM_LICENCE_URL=https://www.gnu.org/licenses/gpl-3.0.html
POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=Mygod
POM_DEVELOPER_NAME=Mygod Studio
/*******************************************************************************
* *
* 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 com.github.shadowsocks.plugin
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.fragment.app.Fragment
import kotlinx.android.parcel.Parcelize
/**
* Based on: https://android.googlesource.com/platform/packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java
*/
abstract class AlertDialogFragment<Arg : Parcelable, Ret : Parcelable> :
AppCompatDialogFragment(), DialogInterface.OnClickListener {
companion object {
private const val KEY_ARG = "arg"
private const val KEY_RET = "ret"
fun <T : Parcelable> getRet(data: Intent) = data.extras!!.getParcelable<T>(KEY_RET)!!
}
protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener)
protected val arg by lazy { arguments!!.getParcelable<Arg>(KEY_ARG)!! }
protected open val ret: Ret? get() = null
fun withArg(arg: Arg) = apply { arguments = Bundle().apply { putParcelable(KEY_ARG, arg) } }
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog =
AlertDialog.Builder(requireContext()).also { it.prepare(this) }.create()
override fun onClick(dialog: DialogInterface?, which: Int) {
targetFragment?.onActivityResult(targetRequestCode, which, ret?.let {
Intent().replaceExtras(Bundle().apply { putParcelable(KEY_RET, it) })
})
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
targetFragment?.onActivityResult(targetRequestCode, Activity.RESULT_CANCELED, null)
}
fun show(target: Fragment, requestCode: Int = 0, tag: String = javaClass.simpleName) {
setTargetFragment(target, requestCode)
show(target.fragmentManager ?: return, tag)
}
}
@Parcelize
class Empty : Parcelable
......@@ -38,7 +38,7 @@ class MainFragment : LeanbackSettingsFragmentCompat() {
override fun onPreferenceDisplayDialog(caller: PreferenceFragmentCompat, pref: Preference?): Boolean {
if (pref?.key == Key.id) {
if ((childFragmentManager.findFragmentById(R.id.settings_preference_fragment_container)
as MainPreferenceFragment).state == BaseService.STOPPED) {
as MainPreferenceFragment).state == BaseService.State.Stopped) {
startPreferenceFragment(ProfilesDialogFragment().apply {
arguments = bundleOf(Pair(LeanbackPreferenceDialogFragmentCompat.ARG_KEY, Key.id))
setTargetFragment(caller, 0)
......
......@@ -13,7 +13,7 @@
app:summary="@string/stat_summary"/>
<Preference
app:key="control.import"
app:title="@string/action_import_file"/>
app:title="@string/action_replace_file"/>
<Preference
app:key="control.export"
app:title="@string/action_export_file"/>
......
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