Commit 1a83ab12 authored by Mygod's avatar Mygod

mobile: Add replace from file feature

Fix #2051.
parent 5a15f730
...@@ -21,11 +21,14 @@ ...@@ -21,11 +21,14 @@
package com.github.shadowsocks.database package com.github.shadowsocks.database
import android.database.sqlite.SQLiteCantOpenDatabaseException import android.database.sqlite.SQLiteCantOpenDatabaseException
import android.util.LongSparseArray
import com.github.shadowsocks.Core import com.github.shadowsocks.Core
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.printLog import com.github.shadowsocks.utils.printLog
import org.json.JSONArray
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.sql.SQLException import java.sql.SQLException
/** /**
...@@ -36,6 +39,7 @@ object ProfileManager { ...@@ -36,6 +39,7 @@ object ProfileManager {
interface Listener { interface Listener {
fun onAdd(profile: Profile) fun onAdd(profile: Profile)
fun onRemove(profileId: Long) fun onRemove(profileId: Long)
fun onCleared()
} }
var listener: Listener? = null var listener: Listener? = null
...@@ -48,6 +52,36 @@ object ProfileManager { ...@@ -48,6 +52,36 @@ object ProfileManager {
return profile 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. * Note: It's caller's responsibility to update DirectBoot profile if necessary.
*/ */
...@@ -78,6 +112,7 @@ object ProfileManager { ...@@ -78,6 +112,7 @@ object ProfileManager {
fun clear() = PrivateDatabase.profileDao.deleteAll().also { fun clear() = PrivateDatabase.profileDao.deleteAll().also {
// listener is not called since this won't be used in mobile submodule // listener is not called since this won't be used in mobile submodule
DirectBoot.clean() DirectBoot.clean()
listener?.onCleared()
} }
@Throws(IOException::class) @Throws(IOException::class)
......
...@@ -97,6 +97,7 @@ ...@@ -97,6 +97,7 @@
<string name="action_export">Export to Clipboard</string> <string name="action_export">Export to Clipboard</string>
<string name="action_import">Import from Clipboard</string> <string name="action_import">Import from Clipboard</string>
<string name="action_import_file">Import from file…</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_msg">Successfully export!</string>
<string name="action_export_err">Failed to export.</string> <string name="action_export_err">Failed to export.</string>
<string name="action_import_msg">Successfully import!</string> <string name="action_import_msg">Successfully import!</string>
......
...@@ -55,7 +55,6 @@ import com.google.android.gms.ads.AdRequest ...@@ -55,7 +55,6 @@ import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView import com.google.android.gms.ads.AdView
import net.glxn.qrgen.android.QRCode import net.glxn.qrgen.android.QRCode
import org.json.JSONArray
class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
companion object { companion object {
...@@ -66,6 +65,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -66,6 +65,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
private const val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL" private const val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL"
private const val REQUEST_IMPORT_PROFILES = 1 private const val REQUEST_IMPORT_PROFILES = 1
private const val REQUEST_REPLACE_PROFILES = 3
private const val REQUEST_EXPORT_PROFILES = 2 private const val REQUEST_EXPORT_PROFILES = 2
} }
...@@ -298,6 +298,11 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -298,6 +298,11 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
notifyItemRemoved(index) notifyItemRemoved(index)
if (profileId == DataStore.profileId) DataStore.profileId = 0 // switch to null profile if (profileId == DataStore.profileId) DataStore.profileId = 0 // switch to null profile
} }
override fun onCleared() {
profiles.clear()
notifyDataSetChanged()
}
} }
private var selectedItem: ProfileViewHolder? = null private var selectedItem: ProfileViewHolder? = null
...@@ -385,13 +390,20 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -385,13 +390,20 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} }
R.id.action_import_file -> { R.id.action_import_file -> {
startFilesForResult(Intent(Intent.ACTION_GET_CONTENT).apply { startFilesForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*" type = "application/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*")) putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*"))
}, REQUEST_IMPORT_PROFILES) }, REQUEST_IMPORT_PROFILES)
true 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 -> { R.id.action_manual_settings -> {
startConfig(ProfileManager.createProfile( startConfig(ProfileManager.createProfile(
Profile().also { Core.currentProfile?.first?.copyFeatureSettingsTo(it) })) Profile().also { Core.currentProfile?.first?.copyFeatureSettingsTo(it) }))
...@@ -407,7 +419,6 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -407,7 +419,6 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} }
R.id.action_export_file -> { R.id.action_export_file -> {
startFilesForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { startFilesForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json" type = "application/json"
putExtra(Intent.EXTRA_TITLE, "profiles.json") // optional title that can be edited putExtra(Intent.EXTRA_TITLE, "profiles.json") // optional title that can be edited
}, REQUEST_EXPORT_PROFILES) }, REQUEST_EXPORT_PROFILES)
...@@ -417,9 +428,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -417,9 +428,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} }
} }
private fun startFilesForResult(intent: Intent?, requestCode: Int) { private fun startFilesForResult(intent: Intent, requestCode: Int) {
try { try {
startActivityForResult(intent, 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()
...@@ -429,27 +440,30 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -429,27 +440,30 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data) if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data)
else when (requestCode) { else when (requestCode) {
REQUEST_IMPORT_PROFILES -> { REQUEST_IMPORT_PROFILES -> {
val feature = Core.currentProfile?.first
var success = false
val activity = activity as MainActivity val activity = activity as MainActivity
for (uri in data!!.datas) try { try {
Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(), ProfileManager.createProfilesFromJson(data!!.datas.asSequence().map {
feature) { activity.contentResolver.openInputStream(it)
ProfileManager.createProfile(it) })
success = true
}
} catch (e: Exception) { } 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 -> { REQUEST_EXPORT_PROFILES -> {
val profiles = ProfileManager.getAllProfiles() val profiles = ProfileManager.serializeToJson()
if (profiles != null) try { if (profiles != null) try {
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
requireContext().contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use { 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) { } catch (e: Exception) {
printLog(e) printLog(e)
......
...@@ -18,6 +18,11 @@ ...@@ -18,6 +18,11 @@
android:id="@+id/action_import_file" android:id="@+id/action_import_file"
android:alphabeticShortcut="o" android:alphabeticShortcut="o"
android:title="@string/action_import_file"/> 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 <item
android:id="@+id/action_manual_settings" android:id="@+id/action_manual_settings"
android:title="@string/add_profile_methods_manual_settings" android:title="@string/add_profile_methods_manual_settings"
......
...@@ -30,7 +30,6 @@ import android.os.DeadObjectException ...@@ -30,7 +30,6 @@ import android.os.DeadObjectException
import android.os.Handler import android.os.Handler
import android.text.format.Formatter import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.util.LongSparseArray
import android.widget.Toast import android.widget.Toast
import androidx.leanback.preference.LeanbackPreferenceFragmentCompat import androidx.leanback.preference.LeanbackPreferenceFragmentCompat
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
...@@ -47,7 +46,6 @@ import com.github.shadowsocks.aidl.IShadowsocksService ...@@ -47,7 +46,6 @@ import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.ShadowsocksConnection import com.github.shadowsocks.aidl.ShadowsocksConnection
import com.github.shadowsocks.aidl.TrafficStats import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.net.HttpsTest import com.github.shadowsocks.net.HttpsTest
import com.github.shadowsocks.net.TcpFastOpen import com.github.shadowsocks.net.TcpFastOpen
...@@ -57,13 +55,12 @@ import com.github.shadowsocks.utils.Key ...@@ -57,13 +55,12 @@ import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.datas import com.github.shadowsocks.utils.datas
import com.github.shadowsocks.utils.printLog import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.readableMessage import com.github.shadowsocks.utils.readableMessage
import org.json.JSONArray
class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksConnection.Callback, class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksConnection.Callback,
OnPreferenceDataStoreChangeListener { OnPreferenceDataStoreChangeListener {
companion object { companion object {
private const val REQUEST_CONNECT = 1 private const val REQUEST_CONNECT = 1
private const val REQUEST_IMPORT_PROFILES = 2 private const val REQUEST_REPLACE_PROFILES = 2
private const val REQUEST_EXPORT_PROFILES = 3 private const val REQUEST_EXPORT_PROFILES = 3
private const val TAG = "MainPreferenceFragment" private const val TAG = "MainPreferenceFragment"
} }
...@@ -263,16 +260,14 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo ...@@ -263,16 +260,14 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo
} }
Key.controlImport -> { Key.controlImport -> {
startFilesForResult(Intent(Intent.ACTION_GET_CONTENT).apply { startFilesForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*" type = "application/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*")) putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*"))
}, REQUEST_IMPORT_PROFILES) }, REQUEST_REPLACE_PROFILES)
true true
} }
Key.controlExport -> { Key.controlExport -> {
startFilesForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { startFilesForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json" type = "application/json"
putExtra(Intent.EXTRA_TITLE, "profiles.json") // optional title that can be edited putExtra(Intent.EXTRA_TITLE, "profiles.json") // optional title that can be edited
}, REQUEST_EXPORT_PROFILES) }, REQUEST_EXPORT_PROFILES)
...@@ -281,9 +276,9 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo ...@@ -281,9 +276,9 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
private fun startFilesForResult(intent: Intent?, requestCode: Int) { private fun startFilesForResult(intent: Intent, requestCode: Int) {
try { try {
startActivityForResult(intent, requestCode) startActivityForResult(intent.addCategory(Intent.CATEGORY_OPENABLE), requestCode)
return return
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { } } catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { }
Toast.makeText(requireContext(), R.string.file_manager_missing, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.file_manager_missing, Toast.LENGTH_SHORT).show()
...@@ -295,23 +290,13 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo ...@@ -295,23 +290,13 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo
Toast.makeText(requireContext(), R.string.vpn_permission_denied, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.vpn_permission_denied, Toast.LENGTH_SHORT).show()
Crashlytics.log(Log.ERROR, TAG, "Failed to start VpnService from onActivityResult: $data") Crashlytics.log(Log.ERROR, TAG, "Failed to start VpnService from onActivityResult: $data")
} }
REQUEST_IMPORT_PROFILES -> { REQUEST_REPLACE_PROFILES -> {
if (resultCode != Activity.RESULT_OK) return if (resultCode != Activity.RESULT_OK) return
val profiles = ProfileManager.getAllProfiles()?.associateBy { it.formattedAddress }
val feature = profiles?.values?.singleOrNull { it.id == DataStore.profileId }
val lazyClear = lazy { ProfileManager.clear() }
val context = requireContext() val context = requireContext()
for (uri in data!!.datas) try { try {
Profile.parseJson(context.contentResolver.openInputStream(uri)!!.bufferedReader().readText(), ProfileManager.createProfilesFromJson(data!!.datas.asSequence().map {
feature) { context.contentResolver.openInputStream(it)
lazyClear.value }, true)
// 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
}
ProfileManager.createProfile(it)
}
} catch (e: Exception) { } catch (e: Exception) {
printLog(e) printLog(e)
Toast.makeText(context, e.readableMessage, Toast.LENGTH_SHORT).show() Toast.makeText(context, e.readableMessage, Toast.LENGTH_SHORT).show()
...@@ -320,12 +305,11 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo ...@@ -320,12 +305,11 @@ class MainPreferenceFragment : LeanbackPreferenceFragmentCompat(), ShadowsocksCo
} }
REQUEST_EXPORT_PROFILES -> { REQUEST_EXPORT_PROFILES -> {
if (resultCode != Activity.RESULT_OK) return if (resultCode != Activity.RESULT_OK) return
val profiles = ProfileManager.getAllProfiles() val profiles = ProfileManager.serializeToJson()
val context = requireContext() val context = requireContext()
if (profiles != null) try { if (profiles != null) try {
val lookup = LongSparseArray<Profile>(profiles.size).apply { profiles.forEach { put(it.id, it) } }
context.contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use { context.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) { } catch (e: Exception) {
printLog(e) printLog(e)
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
android:summary="@string/stat_summary"/> android:summary="@string/stat_summary"/>
<Preference <Preference
android:key="control.import" android:key="control.import"
android:title="@string/action_import_file"/> android:title="@string/action_replace_file"/>
<Preference <Preference
android:key="control.export" android:key="control.export"
android:title="@string/action_export_file"/> android: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