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
import com.github.shadowsocks.Core
import com.github.shadowsocks.net.Subnet
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 kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
......@@ -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>() {
override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
}
private object StringSorter : DefaultSorter<String>()
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 proxyHostnames = SortedList(String::class.java, StringSorter)
......
......@@ -23,6 +23,7 @@ package com.github.shadowsocks.database
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.shadowsocks.Core.app
......@@ -31,7 +32,8 @@ import com.github.shadowsocks.utils.Key
import kotlinx.coroutines.GlobalScope
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() {
companion object {
private val instance by lazy {
......@@ -39,7 +41,8 @@ abstract class PrivateDatabase : RoomDatabase() {
addMigrations(
Migration26,
Migration27,
Migration28
Migration28,
Migration29
)
allowMainThreadQueries()
enableMultiInstanceInvalidation()
......@@ -70,4 +73,9 @@ abstract class PrivateDatabase : RoomDatabase() {
override fun migrate(database: SupportSQLiteDatabase) =
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.*
data class Profile(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
// user configurable fields
var name: String? = "",
var host: String = sponsored,
var remotePort: Int = 8388,
......@@ -64,15 +66,37 @@ data class Profile(
@TargetApi(28)
var metered: Boolean = false,
var individual: String = "",
var plugin: String? = null,
var udpFallback: Long? = null,
// managed fields
var subscription: SubscriptionStatus = SubscriptionStatus.UserConfigured,
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 {
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 {
private const val TAG = "ShadowParser"
private const val serialVersionUID = 1L
......@@ -139,9 +163,11 @@ data class Profile(
private val fallbackMap = mutableMapOf<Profile, Profile>()
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
get() = // asBoolean attempts to cast everything to boolean
(this as? JsonPrimitive)?.run { if (isBoolean) asBoolean else null }
private val JsonElement?.optInt get() = try {
private val JsonElement?.optInt
get() = try {
(this as? JsonPrimitive)?.asInt
} catch (_: NumberFormatException) {
null
......@@ -176,8 +202,8 @@ data class Profile(
(json["proxy_apps"] as? JsonObject)?.also {
proxyApps = it["enabled"].optBoolean ?: proxyApps
bypass = it["bypass"].optBoolean ?: bypass
individual = (json["android_list"] as? JsonArray)?.asIterable()?.joinToString("\n") ?:
individual
individual = (it["android_list"] as? JsonArray)?.asIterable()?.mapNotNull { it.optString }
?.joinToString("\n") ?: individual
}
udpdns = json["udpdns"].optBoolean ?: udpdns
(json["udp_fallback"] as? JsonObject)?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
......@@ -194,6 +220,7 @@ data class Profile(
// ignore other types
}
}
fun finalize(create: (Profile) -> Unit) {
val profiles = ProfileManager.getAllProfiles() ?: emptyList()
for ((profile, fallback) in fallbackMap) {
......@@ -210,6 +237,7 @@ data class Profile(
}
}
}
fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> Unit) {
JsonParser(feature).run {
process(json)
......@@ -273,6 +301,7 @@ data class Profile(
if (!name.isNullOrEmpty()) builder.fragment(name)
return builder.build()
}
override fun toString() = toUri().toString()
fun toJson(profiles: LongSparseArray<Profile>? = null): JSONObject = JSONObject().apply {
......
......@@ -54,6 +54,38 @@ object ProfileManager {
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) {
val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
val feature = if (replace) {
......@@ -74,6 +106,7 @@ object ProfileManager {
}
}
}
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) } }
......
/*******************************************************************************
* *
* 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 @@
<string name="vpn_connected">Connected, tap to check connection</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 -->
<string name="custom_rules">Custom rules</string>
<string name="action_add_rule">Add rule(s)…</string>
......
......@@ -70,6 +70,7 @@ dependencies {
implementation 'com.twofortyfouram:android-plugin-api-for-locale:1.0.4'
implementation 'me.zhanghai.android.fastscroll:library:1.1.0'
implementation 'xyz.belvi.mobilevision:barcodescanner:2.0.3'
implementation 'me.zhanghai.android.materialprogressbar:library:1.6.1'
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test:runner:$androidTestVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion"
......
......@@ -48,6 +48,7 @@ import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener
import com.github.shadowsocks.subscription.SubscriptionFragment
import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.SingleInstanceActivity
import com.github.shadowsocks.widget.ListHolderListener
......@@ -215,6 +216,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Callback, OnPref
return true
}
R.id.customRules -> displayFragment(CustomRulesFragment())
R.id.subscriptions -> displayFragment(SubscriptionFragment())
else -> return false
}
item.isChecked = true
......
......@@ -118,6 +118,18 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
initPlugins()
udpFallback = findPreference(Key.udpFallback)!!
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?) {
......
......@@ -91,6 +91,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
* Is ProfilesFragment editable at all.
*/
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
......@@ -161,6 +162,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
private val text2 = itemView.findViewById<TextView>(android.R.id.text2)
private val traffic = itemView.findViewById<TextView>(R.id.traffic)
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)
init {
......@@ -168,7 +170,12 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
item = ProfileManager.getProfile(item.id)!!
startConfig(item)
}
subscription.setOnClickListener {
item = ProfileManager.getProfile(item.id)!!
startConfig(item)
}
TooltipCompat.setTooltipText(edit, edit.contentDescription)
TooltipCompat.setTooltipText(subscription, subscription.contentDescription)
itemView.setOnClickListener(this)
val share = itemView.findViewById<View>(R.id.share)
share.setOnClickListener {
......@@ -257,6 +264,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}.build().loadAd(AdRequest.Builder().apply {
addTestDevice("B08FC1764A7B250E91EA9D0D5EBEB208")
addTestDevice("7509D18EB8AF82F915874FEF53877A64")
addTestDevice("F58907F28184A828DD0DB6F8E38189C6")
addTestDevice("FE983F496D7C5C1878AA163D9420CA97")
}.build())
} else if (nativeAd != null) populateUnifiedNativeAdView(nativeAd!!, nativeAdView!!)
}
......@@ -274,6 +283,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
val editable = isProfileEditable(item.id)
edit.isEnabled = editable
edit.alpha = if (editable) 1F else .5F
subscription.isEnabled = editable
subscription.alpha = if (editable) 1F else .5F
var tx = item.tx
var rx = item.rx
statsCache[item.id]?.apply {
......@@ -297,6 +308,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
itemView.isSelected = false
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?) {
......@@ -337,6 +356,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) = holder.bind(profiles[position])
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder = ProfileViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.layout_profile, parent, false))
override fun getItemCount(): Int = profiles.size
override fun getItemId(position: Int): Long = profiles[position].id
......@@ -365,6 +385,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
updated.add(first)
notifyItemMoved(from, to)
}
fun commitMove() {
updated.forEach { ProfileManager.updateProfile(it) }
updated.clear()
......@@ -374,12 +395,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
profiles.removeAt(pos)
notifyItemRemoved(pos)
}
fun undo(actions: List<Pair<Int, Profile>>) {
for ((index, item) in actions) {
profiles.add(index, item)
notifyItemInserted(index)
}
}
fun commit(actions: List<Pair<Int, Profile>>) {
for ((_, item) in actions) ProfileManager.delProfile(item.id)
}
......@@ -388,12 +411,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
val index = profiles.indexOfFirst { it.id == id }
if (index >= 0) notifyItemChanged(index)
}
fun deepRefreshId(id: Long) {
val index = profiles.indexOfFirst { it.id == id }
if (index < 0) return
profiles[index] = ProfileManager.getProfile(id)!!
notifyItemChanged(index)
}
override fun onRemove(profileId: Long) {
val index = profiles.indexOfFirst { it.id == profileId }
if (index < 0) return
......@@ -450,6 +475,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int =
if (isProfileEditable((viewHolder as ProfileViewHolder).item.id))
super.getSwipeDirs(recyclerView, viewHolder) else 0
override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int =
if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0
......@@ -458,11 +484,13 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
profilesAdapter.remove(index)
undoManager.remove(Pair(index, (viewHolder as ProfileViewHolder).item))
}
override fun onMove(recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
profilesAdapter.move(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
profilesAdapter.commitMove()
......@@ -537,7 +565,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
try {
startActivityForResult(intent.addCategory(Intent.CATEGORY_OPENABLE), requestCode)
return
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { }
} catch (_: ActivityNotFoundException) {
} catch (_: SecurityException) {
}
(activity as MainActivity).snackbar(getString(R.string.file_manager_missing)).show()
}
......@@ -585,6 +615,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
profilesAdapter.refreshId(profileId)
}
}
fun onTrafficPersisted(profileId: Long) {
statsCache.remove(profileId)
profilesAdapter.deepRefreshId(profileId)
......
......@@ -36,33 +36,31 @@ class UndoSnackbarManager<in T>(private val activity: MainActivity, private val
private val recycleBin = ArrayList<Pair<Int, T>>()
private val removedCallback = object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
when (event) {
DISMISS_EVENT_SWIPE, DISMISS_EVENT_MANUAL, DISMISS_EVENT_TIMEOUT -> {
if (last === transientBottomBar && event != DISMISS_EVENT_ACTION) {
commit?.invoke(recycleBin)
recycleBin.clear()
}
}
last = null
}
}
}
private var last: Snackbar? = null
fun remove(items: Collection<Pair<Int, T>>) {
recycleBin.addAll(items)
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)
setAction(R.string.undo) {
undo(recycleBin.reversed())
recycleBin.clear()
}
last = this
show()
}
}
fun remove(vararg items: Pair<Int, T>) = remove(items.toList())
fun flush() {
last?.dismiss()
last = null
}
fun flush() = last?.dismiss()
}
<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 @@
tools:text="@string/profile_name"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/share"
android:id="@+id/subscription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/share"
android:contentDescription="@string/subscriptions"
android:focusable="true"
android:nextFocusLeft="@+id/container"
android:padding="12dp"
app:srcCompat="@drawable/ic_social_share"/>
android:visibility="gone"
app:srcCompat="@drawable/ic_cloud_queue"/>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/edit"
......@@ -55,6 +55,18 @@
android:focusable="true"
android:padding="12dp"
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>
<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 @@
<item android:id="@+id/profiles"
android:title="@string/profiles"
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"
android:title="@string/custom_rules"
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