Unverified Commit 406edac1 authored by Max Lv's avatar Max Lv Committed by GitHub

Merge pull request #2392 from shadowsocks/subscription

Add subscription support
parents ce361da0 fed0ca8b
{
"formatVersion": 1,
"database": {
"version": 29,
"identityHash": "5b5c55a1277c63e14416316f9198ed43",
"entities": [
{
"tableName": "Profile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `host` TEXT NOT NULL, `remotePort` INTEGER NOT NULL, `password` TEXT NOT NULL, `method` TEXT NOT NULL, `route` TEXT NOT NULL, `remoteDns` TEXT NOT NULL, `proxyApps` INTEGER NOT NULL, `bypass` INTEGER NOT NULL, `udpdns` INTEGER NOT NULL, `ipv6` INTEGER NOT NULL, `metered` INTEGER NOT NULL, `individual` TEXT NOT NULL, `plugin` TEXT, `udpFallback` INTEGER, `subscription` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "remotePort",
"columnName": "remotePort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "method",
"columnName": "method",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "route",
"columnName": "route",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "remoteDns",
"columnName": "remoteDns",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "proxyApps",
"columnName": "proxyApps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bypass",
"columnName": "bypass",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "udpdns",
"columnName": "udpdns",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ipv6",
"columnName": "ipv6",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "metered",
"columnName": "metered",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "individual",
"columnName": "individual",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "plugin",
"columnName": "plugin",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "udpFallback",
"columnName": "udpFallback",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "subscription",
"columnName": "subscription",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tx",
"columnName": "tx",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rx",
"columnName": "rx",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userOrder",
"columnName": "userOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "KeyValuePair",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueType",
"columnName": "valueType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"key"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5b5c55a1277c63e14416316f9198ed43')"
]
}
}
\ No newline at end of file
...@@ -27,6 +27,8 @@ import com.crashlytics.android.Crashlytics ...@@ -27,6 +27,8 @@ import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.Core import com.github.shadowsocks.Core
import com.github.shadowsocks.net.Subnet import com.github.shadowsocks.net.Subnet
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.BaseSorter
import com.github.shadowsocks.utils.URLSorter
import com.github.shadowsocks.utils.asIterable import com.github.shadowsocks.utils.asIterable
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
...@@ -111,26 +113,11 @@ class Acl { ...@@ -111,26 +113,11 @@ class Acl {
} }
} }
private abstract class BaseSorter<T> : SortedList.Callback<T>() {
override fun onInserted(position: Int, count: Int) { }
override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem
override fun onMoved(fromPosition: Int, toPosition: Int) { }
override fun onChanged(position: Int, count: Int) { }
override fun onRemoved(position: Int, count: Int) { }
override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2
override fun compare(o1: T?, o2: T?): Int =
if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2)
abstract fun compareNonNull(o1: T, o2: T): Int
}
private open class DefaultSorter<T : Comparable<T>> : BaseSorter<T>() { private open class DefaultSorter<T : Comparable<T>> : BaseSorter<T>() {
override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2) override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
} }
private object StringSorter : DefaultSorter<String>() private object StringSorter : DefaultSorter<String>()
private object SubnetSorter : DefaultSorter<Subnet>() private object SubnetSorter : DefaultSorter<Subnet>()
private object URLSorter : BaseSorter<URL>() {
private val ordering = compareBy<URL>({ it.host }, { it.port }, { it.file }, { it.protocol })
override fun compareNonNull(o1: URL, o2: URL): Int = ordering.compare(o1, o2)
}
val bypassHostnames = SortedList(String::class.java, StringSorter) val bypassHostnames = SortedList(String::class.java, StringSorter)
val proxyHostnames = SortedList(String::class.java, StringSorter) val proxyHostnames = SortedList(String::class.java, StringSorter)
......
...@@ -23,6 +23,7 @@ package com.github.shadowsocks.database ...@@ -23,6 +23,7 @@ package com.github.shadowsocks.database
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.shadowsocks.Core.app import com.github.shadowsocks.Core.app
...@@ -31,7 +32,8 @@ import com.github.shadowsocks.utils.Key ...@@ -31,7 +32,8 @@ import com.github.shadowsocks.utils.Key
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Database(entities = [Profile::class, KeyValuePair::class], version = 28) @Database(entities = [Profile::class, KeyValuePair::class], version = 29)
@TypeConverters(Profile.SubscriptionStatus::class)
abstract class PrivateDatabase : RoomDatabase() { abstract class PrivateDatabase : RoomDatabase() {
companion object { companion object {
private val instance by lazy { private val instance by lazy {
...@@ -39,7 +41,8 @@ abstract class PrivateDatabase : RoomDatabase() { ...@@ -39,7 +41,8 @@ abstract class PrivateDatabase : RoomDatabase() {
addMigrations( addMigrations(
Migration26, Migration26,
Migration27, Migration27,
Migration28 Migration28,
Migration29
) )
allowMainThreadQueries() allowMainThreadQueries()
enableMultiInstanceInvalidation() enableMultiInstanceInvalidation()
...@@ -70,4 +73,9 @@ abstract class PrivateDatabase : RoomDatabase() { ...@@ -70,4 +73,9 @@ abstract class PrivateDatabase : RoomDatabase() {
override fun migrate(database: SupportSQLiteDatabase) = override fun migrate(database: SupportSQLiteDatabase) =
database.execSQL("ALTER TABLE `Profile` ADD COLUMN `metered` INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE `Profile` ADD COLUMN `metered` INTEGER NOT NULL DEFAULT 0")
} }
object Migration29 : Migration(28, 29) {
override fun migrate(database: SupportSQLiteDatabase) =
database.execSQL("ALTER TABLE `Profile` ADD COLUMN `subscription` INTEGER NOT NULL DEFAULT " +
Profile.SubscriptionStatus.UserConfigured.persistedValue)
}
} }
...@@ -50,6 +50,8 @@ import java.util.* ...@@ -50,6 +50,8 @@ import java.util.*
data class Profile( data class Profile(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
var id: Long = 0, var id: Long = 0,
// user configurable fields
var name: String? = "", var name: String? = "",
var host: String = sponsored, var host: String = sponsored,
var remotePort: Int = 8388, var remotePort: Int = 8388,
...@@ -64,15 +66,37 @@ data class Profile( ...@@ -64,15 +66,37 @@ data class Profile(
@TargetApi(28) @TargetApi(28)
var metered: Boolean = false, var metered: Boolean = false,
var individual: String = "", var individual: String = "",
var plugin: String? = null,
var udpFallback: Long? = null,
// managed fields
var subscription: SubscriptionStatus = SubscriptionStatus.UserConfigured,
var tx: Long = 0, var tx: Long = 0,
var rx: Long = 0, var rx: Long = 0,
var userOrder: Long = 0, var userOrder: Long = 0,
var plugin: String? = null,
var udpFallback: Long? = null,
@Ignore // not persisted in db, only used by direct boot @Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false var dirty: Boolean = false
) : Parcelable, Serializable { ) : Parcelable, Serializable {
enum class SubscriptionStatus(val persistedValue: Int) {
UserConfigured(0),
Active(1),
/**
* This profile is no longer present in subscriptions.
*/
Obsolete(2),
;
companion object {
@JvmStatic
@TypeConverter
fun of(value: Int) = values().single { it.persistedValue == value }
@JvmStatic
@TypeConverter
fun toInt(status: SubscriptionStatus) = status.persistedValue
}
}
companion object { companion object {
private const val TAG = "ShadowParser" private const val TAG = "ShadowParser"
private const val serialVersionUID = 1L private const val serialVersionUID = 1L
...@@ -139,13 +163,15 @@ data class Profile( ...@@ -139,13 +163,15 @@ data class Profile(
private val fallbackMap = mutableMapOf<Profile, Profile>() private val fallbackMap = mutableMapOf<Profile, Profile>()
private val JsonElement?.optString get() = (this as? JsonPrimitive)?.asString private val JsonElement?.optString get() = (this as? JsonPrimitive)?.asString
private val JsonElement?.optBoolean get() = // asBoolean attempts to cast everything to boolean private val JsonElement?.optBoolean
(this as? JsonPrimitive)?.run { if (isBoolean) asBoolean else null } get() = // asBoolean attempts to cast everything to boolean
private val JsonElement?.optInt get() = try { (this as? JsonPrimitive)?.run { if (isBoolean) asBoolean else null }
(this as? JsonPrimitive)?.asInt private val JsonElement?.optInt
} catch (_: NumberFormatException) { get() = try {
null (this as? JsonPrimitive)?.asInt
} } catch (_: NumberFormatException) {
null
}
private fun tryParse(json: JsonObject, fallback: Boolean = false): Profile? { private fun tryParse(json: JsonObject, fallback: Boolean = false): Profile? {
val host = json["server"].optString val host = json["server"].optString
...@@ -176,8 +202,8 @@ data class Profile( ...@@ -176,8 +202,8 @@ data class Profile(
(json["proxy_apps"] as? JsonObject)?.also { (json["proxy_apps"] as? JsonObject)?.also {
proxyApps = it["enabled"].optBoolean ?: proxyApps proxyApps = it["enabled"].optBoolean ?: proxyApps
bypass = it["bypass"].optBoolean ?: bypass bypass = it["bypass"].optBoolean ?: bypass
individual = (json["android_list"] as? JsonArray)?.asIterable()?.joinToString("\n") ?: individual = (it["android_list"] as? JsonArray)?.asIterable()?.mapNotNull { it.optString }
individual ?.joinToString("\n") ?: individual
} }
udpdns = json["udpdns"].optBoolean ?: udpdns udpdns = json["udpdns"].optBoolean ?: udpdns
(json["udp_fallback"] as? JsonObject)?.let { tryParse(it, true) }?.also { fallbackMap[this] = it } (json["udp_fallback"] as? JsonObject)?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
...@@ -194,6 +220,7 @@ data class Profile( ...@@ -194,6 +220,7 @@ data class Profile(
// ignore other types // ignore other types
} }
} }
fun finalize(create: (Profile) -> Unit) { fun finalize(create: (Profile) -> Unit) {
val profiles = ProfileManager.getAllProfiles() ?: emptyList() val profiles = ProfileManager.getAllProfiles() ?: emptyList()
for ((profile, fallback) in fallbackMap) { for ((profile, fallback) in fallbackMap) {
...@@ -210,6 +237,7 @@ data class Profile( ...@@ -210,6 +237,7 @@ data class Profile(
} }
} }
} }
fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> Unit) { fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> Unit) {
JsonParser(feature).run { JsonParser(feature).run {
process(json) process(json)
...@@ -273,6 +301,7 @@ data class Profile( ...@@ -273,6 +301,7 @@ data class Profile(
if (!name.isNullOrEmpty()) builder.fragment(name) if (!name.isNullOrEmpty()) builder.fragment(name)
return builder.build() return builder.build()
} }
override fun toString() = toUri().toString() override fun toString() = toUri().toString()
fun toJson(profiles: LongSparseArray<Profile>? = null): JSONObject = JSONObject().apply { fun toJson(profiles: LongSparseArray<Profile>? = null): JSONObject = JSONObject().apply {
......
...@@ -54,6 +54,38 @@ object ProfileManager { ...@@ -54,6 +54,38 @@ object ProfileManager {
return profile return profile
} }
fun createProfilesFromSubscription(jsons: Sequence<InputStream>, replace: Boolean,
oldProfiles: List<Profile>?) {
val profiles = oldProfiles?.associateBy { it.formattedAddress }
val feature = profiles?.values?.singleOrNull { it.id == DataStore.profileId }
val lazyClear = lazy { clear() }
jsons.asIterable().forEachTry { json ->
Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
if (replace) {
lazyClear.value
}
// if two profiles has the same address, treat them as the same profile and copy settings over
profiles?.get(it.formattedAddress)?.apply {
it.tx = tx
it.rx = rx
it.individual = individual
it.route = route
it.bypass = bypass
it.ipv6 = ipv6
it.metered = metered
it.proxyApps = proxyApps
it.remoteDns = remoteDns
it.udpdns = udpdns
it.udpFallback = udpFallback
}
it.subscription = Profile.SubscriptionStatus.Active
createProfile(it)
}
}
}
fun createProfilesFromJson(jsons: Sequence<InputStream>, replace: Boolean = false) { fun createProfilesFromJson(jsons: Sequence<InputStream>, replace: Boolean = false) {
val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
val feature = if (replace) { val feature = if (replace) {
...@@ -74,6 +106,7 @@ object ProfileManager { ...@@ -74,6 +106,7 @@ object ProfileManager {
} }
} }
} }
fun serializeToJson(profiles: List<Profile>? = getAllProfiles()): JSONArray? { fun serializeToJson(profiles: List<Profile>? = getAllProfiles()): JSONArray? {
if (profiles == null) return null if (profiles == null) return null
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } } val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
......
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 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.subscription
import androidx.recyclerview.widget.SortedList
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.URLSorter
import com.github.shadowsocks.utils.asIterable
import java.io.Reader
import java.net.URL
class Subscription {
companion object {
const val SUBSCRIPTION = "subscription"
var instance: Subscription
get() {
val sub = Subscription()
val str = DataStore.publicStore.getString(SUBSCRIPTION)
if (str != null) sub.fromReader(str.reader())
return sub
}
set(value) = DataStore.publicStore.putString(SUBSCRIPTION, value.toString())
}
val urls = SortedList(URL::class.java, URLSorter)
fun fromReader(reader: Reader): Subscription {
urls.clear()
reader.useLines {
for (line in it) {
urls.add(URL(line))
}
}
return this
}
override fun toString(): String {
val result = StringBuilder()
result.append(urls.asIterable().joinToString("\n"))
return result.toString()
}
}
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 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.utils
import androidx.recyclerview.widget.SortedList
import java.net.URL
abstract class BaseSorter<T> : SortedList.Callback<T>() {
override fun onInserted(position: Int, count: Int) { }
override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem
override fun onMoved(fromPosition: Int, toPosition: Int) { }
override fun onChanged(position: Int, count: Int) { }
override fun onRemoved(position: Int, count: Int) { }
override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2
override fun compare(o1: T?, o2: T?): Int =
if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2)
abstract fun compareNonNull(o1: T, o2: T): Int
}
object URLSorter : BaseSorter<URL>() {
private val ordering = compareBy<URL>({ it.host }, { it.port }, { it.file }, { it.protocol })
override fun compareNonNull(o1: URL, o2: URL): Int = ordering.compare(o1, o2)
}
...@@ -135,6 +135,12 @@ ...@@ -135,6 +135,12 @@
<string name="vpn_connected">Connected, tap to check connection</string> <string name="vpn_connected">Connected, tap to check connection</string>
<string name="not_connected">Not connected</string> <string name="not_connected">Not connected</string>
<!-- subscriptions -->
<string name="subscriptions">Subscriptions</string>
<string name="add_subscription">Add a subscription</string>
<string name="edit_subscription">Edit subscription</string>
<string name="update_subscription">Refresh servers from subscription</string>
<!-- acl --> <!-- acl -->
<string name="custom_rules">Custom rules</string> <string name="custom_rules">Custom rules</string>
<string name="action_add_rule">Add rule(s)…</string> <string name="action_add_rule">Add rule(s)…</string>
......
...@@ -70,6 +70,7 @@ dependencies { ...@@ -70,6 +70,7 @@ dependencies {
implementation 'com.twofortyfouram:android-plugin-api-for-locale:1.0.4' implementation 'com.twofortyfouram:android-plugin-api-for-locale:1.0.4'
implementation 'me.zhanghai.android.fastscroll:library:1.1.0' implementation 'me.zhanghai.android.fastscroll:library:1.1.0'
implementation 'xyz.belvi.mobilevision:barcodescanner:2.0.3' implementation 'xyz.belvi.mobilevision:barcodescanner:2.0.3'
implementation 'me.zhanghai.android.materialprogressbar:library:1.6.1'
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test:runner:$androidTestVersion" androidTestImplementation "androidx.test:runner:$androidTestVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion"
......
...@@ -48,6 +48,7 @@ import com.github.shadowsocks.aidl.TrafficStats ...@@ -48,6 +48,7 @@ import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener
import com.github.shadowsocks.subscription.SubscriptionFragment
import com.github.shadowsocks.utils.Key import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.SingleInstanceActivity import com.github.shadowsocks.utils.SingleInstanceActivity
import com.github.shadowsocks.widget.ListHolderListener import com.github.shadowsocks.widget.ListHolderListener
...@@ -215,6 +216,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref ...@@ -215,6 +216,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
return true return true
} }
R.id.customRules -> displayFragment(CustomRulesFragment()) R.id.customRules -> displayFragment(CustomRulesFragment())
R.id.subscriptions -> displayFragment(SubscriptionFragment())
else -> return false else -> return false
} }
item.isChecked = true item.isChecked = true
......
...@@ -118,6 +118,18 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), ...@@ -118,6 +118,18 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
initPlugins() initPlugins()
udpFallback = findPreference(Key.udpFallback)!! udpFallback = findPreference(Key.udpFallback)!!
DataStore.privateStore.registerChangeListener(this) DataStore.privateStore.registerChangeListener(this)
val profile = ProfileManager.getProfile(profileId) ?: Profile()
if (profile.subscription == Profile.SubscriptionStatus.Active) {
findPreference<Preference>(Key.name)!!.isEnabled = false
findPreference<Preference>(Key.host)!!.isEnabled = false
findPreference<Preference>(Key.password)!!.isEnabled = false
findPreference<Preference>(Key.method)!!.isEnabled = false
findPreference<Preference>(Key.remotePort)!!.isEnabled = false
findPreference<Preference>(Key.plugin)!!.isEnabled = false
findPreference<Preference>(Key.pluginConfigure)!!.isEnabled = false
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
......
...@@ -91,6 +91,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -91,6 +91,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
* Is ProfilesFragment editable at all. * Is ProfilesFragment editable at all.
*/ */
private val isEnabled get() = (activity as MainActivity).state.let { it.canStop || it == BaseService.State.Stopped } private val isEnabled get() = (activity as MainActivity).state.let { it.canStop || it == BaseService.State.Stopped }
private fun isProfileEditable(id: Long) = private fun isProfileEditable(id: Long) =
(activity as MainActivity).state == BaseService.State.Stopped || id !in Core.activeProfileIds (activity as MainActivity).state == BaseService.State.Stopped || id !in Core.activeProfileIds
...@@ -161,6 +162,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -161,6 +162,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
private val text2 = itemView.findViewById<TextView>(android.R.id.text2) private val text2 = itemView.findViewById<TextView>(android.R.id.text2)
private val traffic = itemView.findViewById<TextView>(R.id.traffic) private val traffic = itemView.findViewById<TextView>(R.id.traffic)
private val edit = itemView.findViewById<View>(R.id.edit) private val edit = itemView.findViewById<View>(R.id.edit)
private val subscription = itemView.findViewById<View>(R.id.subscription)
private val adContainer = itemView.findViewById<LinearLayout>(R.id.ad_container) private val adContainer = itemView.findViewById<LinearLayout>(R.id.ad_container)
init { init {
...@@ -168,7 +170,12 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -168,7 +170,12 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
item = ProfileManager.getProfile(item.id)!! item = ProfileManager.getProfile(item.id)!!
startConfig(item) startConfig(item)
} }
subscription.setOnClickListener {
item = ProfileManager.getProfile(item.id)!!
startConfig(item)
}
TooltipCompat.setTooltipText(edit, edit.contentDescription) TooltipCompat.setTooltipText(edit, edit.contentDescription)
TooltipCompat.setTooltipText(subscription, subscription.contentDescription)
itemView.setOnClickListener(this) itemView.setOnClickListener(this)
val share = itemView.findViewById<View>(R.id.share) val share = itemView.findViewById<View>(R.id.share)
share.setOnClickListener { share.setOnClickListener {
...@@ -257,6 +264,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -257,6 +264,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}.build().loadAd(AdRequest.Builder().apply { }.build().loadAd(AdRequest.Builder().apply {
addTestDevice("B08FC1764A7B250E91EA9D0D5EBEB208") addTestDevice("B08FC1764A7B250E91EA9D0D5EBEB208")
addTestDevice("7509D18EB8AF82F915874FEF53877A64") addTestDevice("7509D18EB8AF82F915874FEF53877A64")
addTestDevice("F58907F28184A828DD0DB6F8E38189C6")
addTestDevice("FE983F496D7C5C1878AA163D9420CA97")
}.build()) }.build())
} else if (nativeAd != null) populateUnifiedNativeAdView(nativeAd!!, nativeAdView!!) } else if (nativeAd != null) populateUnifiedNativeAdView(nativeAd!!, nativeAdView!!)
} }
...@@ -274,6 +283,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -274,6 +283,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
val editable = isProfileEditable(item.id) val editable = isProfileEditable(item.id)
edit.isEnabled = editable edit.isEnabled = editable
edit.alpha = if (editable) 1F else .5F edit.alpha = if (editable) 1F else .5F
subscription.isEnabled = editable
subscription.alpha = if (editable) 1F else .5F
var tx = item.tx var tx = item.tx
var rx = item.rx var rx = item.rx
statsCache[item.id]?.apply { statsCache[item.id]?.apply {
...@@ -297,6 +308,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -297,6 +308,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
itemView.isSelected = false itemView.isSelected = false
if (selectedItem === this) selectedItem = null if (selectedItem === this) selectedItem = null
} }
if (item.subscription == Profile.SubscriptionStatus.Active) {
edit.visibility = View.GONE
subscription.visibility = View.VISIBLE
} else {
edit.visibility = View.VISIBLE
subscription.visibility = View.GONE
}
} }
override fun onClick(v: View?) { override fun onClick(v: View?) {
...@@ -337,6 +356,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -337,6 +356,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) = holder.bind(profiles[position]) override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) = holder.bind(profiles[position])
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder = ProfileViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder = ProfileViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.layout_profile, parent, false)) LayoutInflater.from(parent.context).inflate(R.layout.layout_profile, parent, false))
override fun getItemCount(): Int = profiles.size override fun getItemCount(): Int = profiles.size
override fun getItemId(position: Int): Long = profiles[position].id override fun getItemId(position: Int): Long = profiles[position].id
...@@ -365,6 +385,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -365,6 +385,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
updated.add(first) updated.add(first)
notifyItemMoved(from, to) notifyItemMoved(from, to)
} }
fun commitMove() { fun commitMove() {
updated.forEach { ProfileManager.updateProfile(it) } updated.forEach { ProfileManager.updateProfile(it) }
updated.clear() updated.clear()
...@@ -374,12 +395,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -374,12 +395,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
profiles.removeAt(pos) profiles.removeAt(pos)
notifyItemRemoved(pos) notifyItemRemoved(pos)
} }
fun undo(actions: List<Pair<Int, Profile>>) { fun undo(actions: List<Pair<Int, Profile>>) {
for ((index, item) in actions) { for ((index, item) in actions) {
profiles.add(index, item) profiles.add(index, item)
notifyItemInserted(index) notifyItemInserted(index)
} }
} }
fun commit(actions: List<Pair<Int, Profile>>) { fun commit(actions: List<Pair<Int, Profile>>) {
for ((_, item) in actions) ProfileManager.delProfile(item.id) for ((_, item) in actions) ProfileManager.delProfile(item.id)
} }
...@@ -388,12 +411,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -388,12 +411,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
val index = profiles.indexOfFirst { it.id == id } val index = profiles.indexOfFirst { it.id == id }
if (index >= 0) notifyItemChanged(index) if (index >= 0) notifyItemChanged(index)
} }
fun deepRefreshId(id: Long) { fun deepRefreshId(id: Long) {
val index = profiles.indexOfFirst { it.id == id } val index = profiles.indexOfFirst { it.id == id }
if (index < 0) return if (index < 0) return
profiles[index] = ProfileManager.getProfile(id)!! profiles[index] = ProfileManager.getProfile(id)!!
notifyItemChanged(index) notifyItemChanged(index)
} }
override fun onRemove(profileId: Long) { override fun onRemove(profileId: Long) {
val index = profiles.indexOfFirst { it.id == profileId } val index = profiles.indexOfFirst { it.id == profileId }
if (index < 0) return if (index < 0) return
...@@ -446,10 +471,11 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -446,10 +471,11 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
ProfileManager.listener = profilesAdapter ProfileManager.listener = profilesAdapter
undoManager = UndoSnackbarManager(activity as MainActivity, profilesAdapter::undo, profilesAdapter::commit) undoManager = UndoSnackbarManager(activity as MainActivity, profilesAdapter::undo, profilesAdapter::commit)
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN,
ItemTouchHelper.START or ItemTouchHelper.END) { ItemTouchHelper.START or ItemTouchHelper.END) {
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int =
if (isProfileEditable((viewHolder as ProfileViewHolder).item.id)) if (isProfileEditable((viewHolder as ProfileViewHolder).item.id))
super.getSwipeDirs(recyclerView, viewHolder) else 0 super.getSwipeDirs(recyclerView, viewHolder) else 0
override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int =
if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0 if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0
...@@ -458,11 +484,13 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -458,11 +484,13 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
profilesAdapter.remove(index) profilesAdapter.remove(index)
undoManager.remove(Pair(index, (viewHolder as ProfileViewHolder).item)) undoManager.remove(Pair(index, (viewHolder as ProfileViewHolder).item))
} }
override fun onMove(recyclerView: RecyclerView, override fun onMove(recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
profilesAdapter.move(viewHolder.adapterPosition, target.adapterPosition) profilesAdapter.move(viewHolder.adapterPosition, target.adapterPosition)
return true return true
} }
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder) super.clearView(recyclerView, viewHolder)
profilesAdapter.commitMove() profilesAdapter.commitMove()
...@@ -537,7 +565,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -537,7 +565,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
try { try {
startActivityForResult(intent.addCategory(Intent.CATEGORY_OPENABLE), requestCode) startActivityForResult(intent.addCategory(Intent.CATEGORY_OPENABLE), requestCode)
return return
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { } } catch (_: ActivityNotFoundException) {
} catch (_: SecurityException) {
}
(activity as MainActivity).snackbar(getString(R.string.file_manager_missing)).show() (activity as MainActivity).snackbar(getString(R.string.file_manager_missing)).show()
} }
...@@ -585,6 +615,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -585,6 +615,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
profilesAdapter.refreshId(profileId) profilesAdapter.refreshId(profileId)
} }
} }
fun onTrafficPersisted(profileId: Long) { fun onTrafficPersisted(profileId: Long) {
statsCache.remove(profileId) statsCache.remove(profileId)
profilesAdapter.deepRefreshId(profileId) profilesAdapter.deepRefreshId(profileId)
......
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 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.subscription
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.TextWatcher
import android.view.*
import android.widget.AdapterView
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.shadowsocks.MainActivity
import com.github.shadowsocks.R
import com.github.shadowsocks.ToolbarFragment
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.utils.asIterable
import com.github.shadowsocks.utils.readableMessage
import com.github.shadowsocks.utils.useCancellable
import com.github.shadowsocks.widget.ListHolderListener
import com.github.shadowsocks.widget.MainListListener
import com.github.shadowsocks.widget.UndoSnackbarManager
import com.google.android.material.textfield.TextInputLayout
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import me.zhanghai.android.fastscroll.FastScrollerBuilder
import me.zhanghai.android.materialprogressbar.MaterialProgressBar
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL
class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
companion object {
private const val REQUEST_CODE_ADD = 1
private const val REQUEST_CODE_EDIT = 2
}
@Parcelize
data class SubItem(val item: String = "") : Parcelable {
fun toURL() = URL(item)
}
@Parcelize
data class SubEditResult(val edited: SubItem, val replacing: SubItem) : Parcelable
class SubDialogFragment : AlertDialogFragment<SubItem, SubEditResult>(),
TextWatcher, AdapterView.OnItemSelectedListener {
private lateinit var editText: EditText
private lateinit var inputLayout: TextInputLayout
private val positive by lazy { (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) }
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
val activity = requireActivity()
@SuppressLint("InflateParams")
val view = activity.layoutInflater.inflate(R.layout.dialog_subscription, null)
editText = view.findViewById(R.id.content)
inputLayout = view.findViewById(R.id.content_layout)
editText.setText(arg.item)
editText.addTextChangedListener(this@SubDialogFragment)
setTitle(R.string.add_subscription)
setPositiveButton(android.R.string.ok, listener)
setNegativeButton(android.R.string.cancel, null)
if (arg.item.isNotEmpty()) setNeutralButton(R.string.delete, listener)
setView(view)
}
override fun onStart() {
super.onStart()
validate()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) = validate(value = s)
override fun onNothingSelected(parent: AdapterView<*>?) = check(false)
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) = validate()
private fun validate(value: Editable = editText.text) {
var message = ""
positive.isEnabled = try {
val url = URL(value.toString())
if ("http".equals(url.protocol, true)) message = getString(R.string.cleartext_http_warning)
true
} catch (e: MalformedURLException) {
message = e.readableMessage
false
}
inputLayout.error = message
}
override fun ret(which: Int) = when (which) {
DialogInterface.BUTTON_POSITIVE -> {
SubEditResult(editText.text.toString().let { text -> SubItem(text) }, arg)
}
DialogInterface.BUTTON_NEUTRAL -> SubEditResult(arg, arg)
else -> null
}
override fun onClick(dialog: DialogInterface?, which: Int) {
if (which != DialogInterface.BUTTON_NEGATIVE) super.onClick(dialog, which)
}
}
private inner class SubViewHolder(view: View) : RecyclerView.ViewHolder(view),
View.OnClickListener {
lateinit var item: URL
private val text = view.findViewById<TextView>(android.R.id.text1)
init {
view.isFocusable = true
view.setOnClickListener(this)
view.setBackgroundResource(R.drawable.background_selectable)
}
fun bind(url: URL) {
item = url
text.text = url.toString()
}
override fun onClick(v: View?) {
SubDialogFragment().withArg(SubItem(item.toString()))
.show(this@SubscriptionFragment, REQUEST_CODE_EDIT)
}
}
private inner class SubscriptionAdapter : RecyclerView.Adapter<SubViewHolder>() {
private val subscription = Subscription.instance
private var savePending = false
override fun onBindViewHolder(holder: SubViewHolder, i: Int) {
holder.bind(subscription.urls[i])
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SubViewHolder(LayoutInflater
.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false))
override fun getItemCount(): Int = subscription.urls.size()
private fun apply() {
if (!savePending) {
savePending = true
list.post {
Subscription.instance = subscription
savePending = false
}
}
}
fun add(url: URL): Int {
val old = subscription.urls.size()
val index = subscription.urls.add(url)
if (old != subscription.urls.size()) {
notifyItemInserted(index)
apply()
}
return index
}
fun remove(i: Int) {
undoManager.remove(Pair(i, subscription.urls[i]))
subscription.urls.removeItemAt(i)
notifyItemRemoved(i)
apply()
}
fun remove(item: Any) {
when (item) {
is URL -> {
notifyItemRemoved(subscription.urls.indexOf(item))
subscription.urls.remove(item)
apply()
}
}
}
fun undo(actions: List<Pair<Int, Any>>) {
for ((_, item) in actions)
when (item) {
is URL -> {
add(item)
}
}
}
}
private val isEnabled get() = (activity as MainActivity).state == BaseService.State.Stopped
private val adapter by lazy { SubscriptionAdapter() }
private lateinit var list: RecyclerView
private lateinit var progress: MaterialProgressBar
private var mode: ActionMode? = null
private lateinit var undoManager: UndoSnackbarManager<Any>
private var fetchJob: Job? = null
private fun fetchServerFromSubscriptions() {
if (fetchJob?.isActive != true) {
val activity = activity as MainActivity
progress.visibility = View.VISIBLE
fetchJob = GlobalScope.launch {
val subscription = Subscription.instance
val oldProfiles = ProfileManager.getAllProfiles()
var replace = true
for (url in subscription.urls.asIterable()) {
try {
val connection = url.openConnection() as HttpURLConnection
connection.useCancellable {
ProfileManager.createProfilesFromSubscription(sequenceOf(connection.inputStream),
replace, oldProfiles)
}
} catch (e: Exception) {
e.printStackTrace()
activity.snackbar(e.readableMessage).show()
} finally {
replace = false
}
}
val userProfiles = oldProfiles?.filter { it.subscription == Profile.SubscriptionStatus.UserConfigured }
if (userProfiles != null) {
for (profile in userProfiles.asIterable()) {
ProfileManager.createProfile(profile)
}
}
progress.post {
progress.visibility = View.INVISIBLE
}
}
}
}
override fun onPause() {
fetchJob?.cancel()
super.onPause()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.layout_subscriptions, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.setOnApplyWindowInsetsListener(ListHolderListener)
toolbar.setTitle(R.string.subscriptions)
toolbar.inflateMenu(R.menu.subscription_menu)
toolbar.setOnMenuItemClickListener(this)
val activity = activity as MainActivity
list = view.findViewById(R.id.list)
list.setOnApplyWindowInsetsListener(MainListListener)
list.layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
list.itemAnimator = DefaultItemAnimator()
list.adapter = adapter
progress = view.findViewById(R.id.indeterminate_horizontal_progress)
FastScrollerBuilder(list).useMd2Style().build()
undoManager = UndoSnackbarManager(activity, adapter::undo)
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.START or ItemTouchHelper.END) {
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int =
if (isEnabled) super.getSwipeDirs(recyclerView, viewHolder) else 0
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) =
adapter.remove(viewHolder.adapterPosition)
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder): Boolean = false
}).attachToRecyclerView(list)
}
override fun onBackPressed(): Boolean {
val mode = mode
return if (mode != null) {
mode.finish()
true
} else super.onBackPressed()
}
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_manual_settings -> {
SubDialogFragment().withArg(SubItem()).show(this, REQUEST_CODE_ADD)
true
}
R.id.action_update_subscription -> {
fetchServerFromSubscriptions()
true
}
else -> false
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val editing = when (requestCode) {
REQUEST_CODE_ADD -> false
REQUEST_CODE_EDIT -> true
else -> return super.onActivityResult(requestCode, resultCode, data)
}
val ret by lazy { AlertDialogFragment.getRet<SubEditResult>(data!!) }
when (resultCode) {
DialogInterface.BUTTON_POSITIVE -> {
if (editing) adapter.remove(ret.replacing.toURL())
adapter.add(ret.edited.toURL()).also { list.post { list.scrollToPosition(it) } }
}
DialogInterface.BUTTON_NEUTRAL -> ret.replacing.toURL().let { item ->
adapter.remove(item)
undoManager.remove(Pair(-1, item))
}
}
}
override fun onDetach() {
undoManager.flush()
mode?.finish()
super.onDetach()
}
}
...@@ -36,33 +36,31 @@ class UndoSnackbarManager<in T>(private val activity: MainActivity, private val ...@@ -36,33 +36,31 @@ class UndoSnackbarManager<in T>(private val activity: MainActivity, private val
private val recycleBin = ArrayList<Pair<Int, T>>() private val recycleBin = ArrayList<Pair<Int, T>>()
private val removedCallback = object : Snackbar.Callback() { private val removedCallback = object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
when (event) { if (last === transientBottomBar && event != DISMISS_EVENT_ACTION) {
DISMISS_EVENT_SWIPE, DISMISS_EVENT_MANUAL, DISMISS_EVENT_TIMEOUT -> { commit?.invoke(recycleBin)
commit?.invoke(recycleBin) recycleBin.clear()
recycleBin.clear() last = null
}
} }
last = null
} }
} }
private var last: Snackbar? = null private var last: Snackbar? = null
fun remove(items: Collection<Pair<Int, T>>) { fun remove(items: Collection<Pair<Int, T>>) {
recycleBin.addAll(items) recycleBin.addAll(items)
val count = recycleBin.size val count = recycleBin.size
last = activity.snackbar(activity.resources.getQuantityString(R.plurals.removed, count, count)).apply { activity.snackbar(activity.resources.getQuantityString(R.plurals.removed, count, count)).apply {
addCallback(removedCallback) addCallback(removedCallback)
setAction(R.string.undo) { setAction(R.string.undo) {
undo(recycleBin.reversed()) undo(recycleBin.reversed())
recycleBin.clear() recycleBin.clear()
} }
last = this
show() show()
} }
} }
fun remove(vararg items: Pair<Int, T>) = remove(items.toList()) fun remove(vararg items: Pair<Int, T>) = remove(items.toList())
fun flush() { fun flush() = last?.dismiss()
last?.dismiss()
last = null
}
} }
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM19,18H6c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4h0.71C7.37,7.69 9.48,6 12,6c3.04,0 5.5,2.46 5.5,5.5v0.5H19c1.66,0 3,1.34 3,3s-1.34,3 -3,3z" />
</vector>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/content_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="12dp"
android:inputType="textNoSuggestions|textMultiLine"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
...@@ -34,16 +34,16 @@ ...@@ -34,16 +34,16 @@
tools:text="@string/profile_name"/> tools:text="@string/profile_name"/>
<androidx.appcompat.widget.AppCompatImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/share" android:id="@+id/subscription"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="top" android:layout_gravity="top"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/share" android:contentDescription="@string/subscriptions"
android:focusable="true" android:focusable="true"
android:nextFocusLeft="@+id/container"
android:padding="12dp" android:padding="12dp"
app:srcCompat="@drawable/ic_social_share"/> android:visibility="gone"
app:srcCompat="@drawable/ic_cloud_queue"/>
<androidx.appcompat.widget.AppCompatImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/edit" android:id="@+id/edit"
...@@ -55,6 +55,18 @@ ...@@ -55,6 +55,18 @@
android:focusable="true" android:focusable="true"
android:padding="12dp" android:padding="12dp"
app:srcCompat="@drawable/ic_image_edit"/> app:srcCompat="@drawable/ic_image_edit"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/share"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/share"
android:focusable="true"
android:nextFocusLeft="@+id/container"
android:padding="12dp"
app:srcCompat="@drawable/ic_social_share"/>
</LinearLayout> </LinearLayout>
<RelativeLayout <RelativeLayout
......
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<include layout="@layout/toolbar_light_dark" />
<me.zhanghai.android.materialprogressbar.MaterialProgressBar
android:id="@+id/indeterminate_horizontal_progress"
style="@style/Widget.MaterialProgressBar.ProgressBar.Horizontal.NoPadding"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:indeterminate="true"
android:visibility="invisible"
app:mpb_progressStyle="horizontal"
app:mpb_showProgressBackground="false"
app:mpb_useIntrinsicPadding="false" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
tools:listitem="@android:layout/simple_list_item_1" />
</FrameLayout>
</LinearLayout>
...@@ -5,6 +5,9 @@ ...@@ -5,6 +5,9 @@
<item android:id="@+id/profiles" <item android:id="@+id/profiles"
android:title="@string/profiles" android:title="@string/profiles"
android:icon="@drawable/ic_action_description"/> android:icon="@drawable/ic_action_description"/>
<item android:id="@+id/subscriptions"
android:title="@string/subscriptions"
android:icon="@drawable/ic_action_cloud_download"/>
<item android:id="@+id/customRules" <item android:id="@+id/customRules"
android:title="@string/custom_rules" android:title="@string/custom_rules"
android:icon="@drawable/ic_action_assignment"/> android:icon="@drawable/ic_action_assignment"/>
......
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/add_subscription"
android:id="@+id/action_manual_settings"
android:icon="@drawable/ic_av_playlist_add"
android:alphabeticShortcut="n"
app:showAsAction="always"/>
<item android:title="@string/update_subscription"
android:id="@+id/action_update_subscription"
android:icon="@drawable/ic_action_cloud_download"
android:alphabeticShortcut="r"
app:showAsAction="ifRoom"/>
</menu>
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