Commit 1a83ab12 authored by Mygod's avatar Mygod

mobile: Add replace from file feature

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