Unverified Commit 0055e53a authored by Max Lv's avatar Max Lv Committed by GitHub

Merge pull request #1892 from Mygod/json

Add import/export to JSON
parents 31fdcfaf ca386a75
......@@ -27,7 +27,7 @@ import org.junit.Test
class ProfileTest {
@Test
fun parsing() {
val results = Profile.findAll("garble ss://YmYtY2ZiOnRlc3RAMTkyLjE2OC4xMDAuMTo4ODg4#example-server garble")
val results = Profile.findAllUrls("garble ss://YmYtY2ZiOnRlc3RAMTkyLjE2OC4xMDAuMTo4ODg4#example-server garble")
.toList()
Assert.assertEquals(1, results.size)
Assert.assertEquals("ss://YmYtY2ZiOnRlc3Q@192.168.100.1:8888#example-server".toUri(),
......
......@@ -223,13 +223,13 @@ class AppManager : AppCompatActivity(), Toolbar.OnMenuItemClickListener {
} else Snackbar.make(appListView, R.string.action_export_err, Snackbar.LENGTH_LONG).show()
return true
}
R.id.action_export -> {
R.id.action_export_clipboard -> {
clipboard.primaryClip = ClipData.newPlainText(Key.individual,
"${DataStore.bypass}\n${DataStore.individual}")
Snackbar.make(appListView, R.string.action_export_msg, Snackbar.LENGTH_LONG).show()
return true
}
R.id.action_import -> {
R.id.action_import_clipboard -> {
val proxiedAppString = clipboard.primaryClip?.getItemAt(0)?.text?.toString()
if (!proxiedAppString.isNullOrEmpty()) {
val i = proxiedAppString!!.indexOf('\n')
......
......@@ -200,7 +200,8 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK) app.startService() else {
if (requestCode != REQUEST_CONNECT) super.onActivityResult(requestCode, resultCode, data)
else if (resultCode == Activity.RESULT_OK) app.startService() else {
snackbar().setText(R.string.vpn_permission_denied).show()
Crashlytics.log(Log.ERROR, TAG, "Failed to start VpnService from onActivityResult: $data")
}
......@@ -272,7 +273,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre
else -> null
}
if (sharedStr.isNullOrEmpty()) return
val profiles = Profile.findAll(sharedStr).toList()
val profiles = Profile.findAllUrls(sharedStr, app.currentProfile).toList()
if (profiles.isEmpty()) {
snackbar().setText(R.string.profile_invalid_input).show()
return
......
......@@ -21,6 +21,7 @@
package com.github.shadowsocks
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
......@@ -48,11 +49,14 @@ import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.PluginConfiguration
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.datas
import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.widget.UndoSnackbarManager
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView
import net.glxn.qrgen.android.QRCode
import org.json.JSONArray
class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
companion object {
......@@ -62,6 +66,8 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
var instance: ProfilesFragment? = null
private const val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL"
private const val REQUEST_IMPORT_PROFILES = 1
private const val REQUEST_EXPORT_PROFILES = 2
}
/**
......@@ -210,7 +216,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
.commitAllowingStateLoss()
true
}
R.id.action_export -> {
R.id.action_export_clipboard -> {
clipboard.primaryClip = ClipData.newPlainText(null, this.item.toString())
true
}
......@@ -363,9 +369,10 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
startActivity(Intent(context, ScannerActivity::class.java))
true
}
R.id.action_import -> {
R.id.action_import_clipboard -> {
try {
val profiles = Profile.findAll(clipboard.primaryClip!!.getItemAt(0).text).toList()
val profiles = Profile.findAllUrls(clipboard.primaryClip!!.getItemAt(0).text, app.currentProfile)
.toList()
if (profiles.isNotEmpty()) {
profiles.forEach { ProfileManager.createProfile(it) }
(activity as MainActivity).snackbar().setText(R.string.action_import_msg).show()
......@@ -377,11 +384,20 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
(activity as MainActivity).snackbar().setText(R.string.action_import_err).show()
true
}
R.id.action_import_file -> {
startActivityForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}, REQUEST_IMPORT_PROFILES)
true
}
R.id.action_manual_settings -> {
startConfig(ProfileManager.createProfile())
startConfig(ProfileManager.createProfile(
Profile().also { app.currentProfile?.copyFeatureSettingsTo(it) }))
true
}
R.id.action_export -> {
R.id.action_export_clipboard -> {
val profiles = ProfileManager.getAllProfiles()
(activity as MainActivity).snackbar().setText(if (profiles != null) {
clipboard.primaryClip = ClipData.newPlainText(null, profiles.joinToString("\n"))
......@@ -389,10 +405,52 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} else R.string.action_export_err).show()
true
}
R.id.action_export_file -> {
startActivityForResult(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)
true
}
else -> false
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data)
else when (requestCode) {
REQUEST_IMPORT_PROFILES -> {
val feature = app.currentProfile
var success = false
val activity = activity as MainActivity
for (uri in data!!.datas) try {
Profile.parseJson(activity.contentResolver.openInputStream(uri).bufferedReader().readText(),
feature).forEach {
ProfileManager.createProfile(it)
success = true
}
} catch (e: Exception) {
printLog(e)
}
activity.snackbar().setText(if (success) R.string.action_import_msg else R.string.action_import_err)
.show()
}
REQUEST_EXPORT_PROFILES -> {
val profiles = ProfileManager.getAllProfiles()
if (profiles != null) try {
requireContext().contentResolver.openOutputStream(data!!.data).bufferedWriter().use {
it.write(JSONArray(profiles.map { it.toJson() }.toTypedArray()).toString(2))
}
} catch (e: Exception) {
printLog(e)
(activity as MainActivity).snackbar(e.localizedMessage).show()
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onTrafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) {
if (profileId != -1L) { // ignore resets from MainActivity
if (bandwidthProfile != profileId) {
......
......@@ -35,9 +35,12 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.app.TaskStackBuilder
import androidx.core.content.getSystemService
import androidx.core.util.forEach
import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.App.Companion.app
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.utils.datas
import com.github.shadowsocks.utils.openBitmap
import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.resolveResourceId
......@@ -107,7 +110,7 @@ class ScannerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListener, Ba
}
override fun onRetrieved(barcode: Barcode) = runOnUiThread {
Profile.findAll(barcode.rawValue).forEach { ProfileManager.createProfile(it) }
Profile.findAllUrls(barcode.rawValue, app.currentProfile).forEach { ProfileManager.createProfile(it) }
navigateUp()
}
override fun onRetrievedMultiple(closetToClick: Barcode?, barcode: MutableList<BarcodeGraphic>?) = check(false)
......@@ -119,31 +122,31 @@ class ScannerActivity : AppCompatActivity(), Toolbar.OnMenuItemClickListener, Ba
}
override fun onMenuItemClick(item: MenuItem) = when (item.itemId) {
R.id.action_import -> {
R.id.action_import_clipboard -> {
startImport(true)
true
}
else -> false
}
private fun startImport(manual: Boolean = false) = startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("image/*")
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true), if (manual) REQUEST_IMPORT else REQUEST_IMPORT_OR_FINISH)
private fun startImport(manual: Boolean = false) = startActivityForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}, if (manual) REQUEST_IMPORT else REQUEST_IMPORT_OR_FINISH)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_IMPORT, REQUEST_IMPORT_OR_FINISH -> if (resultCode == Activity.RESULT_OK) {
val feature = app.currentProfile
var success = false
var list = listOfNotNull(data?.data)
val clipData = data?.clipData
if (clipData != null) list += (0 until clipData.itemCount).map { clipData.getItemAt(it).uri }
for (uri in list) try {
val barcodes = detector.detect(Frame.Builder()
.setBitmap(contentResolver.openBitmap(uri)).build())
for (i in 0 until barcodes.size()) Profile.findAll(barcodes.valueAt(i).rawValue).forEach {
ProfileManager.createProfile(it)
success = true
}
for (uri in data!!.datas) try {
detector.detect(Frame.Builder().setBitmap(contentResolver.openBitmap(uri)).build())
.forEach { _, barcode ->
Profile.findAllUrls(barcode.rawValue, feature).forEach {
ProfileManager.createProfile(it)
success = true
}
}
} catch (e: Exception) {
printLog(e)
}
......
......@@ -420,7 +420,7 @@ class CustomRulesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener,
dialog.show()
true
}
R.id.action_import -> {
R.id.action_import_clipboard -> {
try {
check(adapter.addToProxy(clipboard.primaryClip!!.getItemAt(0).text.toString()) != null)
} catch (exc: Exception) {
......
......@@ -47,6 +47,9 @@ import com.github.shadowsocks.plugin.PluginOptions
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.*
import com.google.firebase.analytics.FirebaseAnalytics
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.net.InetAddress
......@@ -54,10 +57,6 @@ import java.net.UnknownHostException
import java.security.MessageDigest
import java.util.*
import java.util.concurrent.TimeUnit
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
/**
* This object uses WeakMap to simulate the effects of multi-inheritance.
......@@ -186,11 +185,7 @@ object BaseService {
internal var shadowsocksConfigFile: File? = null
internal fun buildShadowsocksConfig(): File {
val profile = profile!!
val config = JSONObject()
.put("server", profile.host)
.put("server_port", profile.remotePort)
.put("password", profile.password)
.put("method", profile.method)
val config = profile.toJson(true)
val pluginPath = pluginPath
if (pluginPath != null) {
val pluginCmd = arrayListOf(pluginPath)
......@@ -200,12 +195,12 @@ object BaseService {
.put("plugin_opts", plugin.toString())
}
// sensitive Shadowsocks config is stored in
val file = File(if (UserManagerCompat.isUserUnlocked(app)) app.filesDir else @TargetApi(24) {
return File(if (UserManagerCompat.isUserUnlocked(app)) app.filesDir else @TargetApi(24) {
app.deviceStorage.noBackupFilesDir // only API 24+ will be in locked state
}, CONFIG_FILE)
shadowsocksConfigFile = file
file.writeText(config.toString())
return file
}, CONFIG_FILE).apply {
shadowsocksConfigFile = this
writeText(config.toString())
}
}
val aclFile: File? get() {
......
......@@ -26,9 +26,14 @@ import android.util.Log
import androidx.core.net.toUri
import androidx.room.*
import com.github.shadowsocks.plugin.PluginConfiguration
import com.github.shadowsocks.plugin.PluginOptions
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.utils.asIterable
import com.github.shadowsocks.utils.parsePort
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.io.Serializable
import java.net.URI
import java.net.URISyntaxException
......@@ -43,13 +48,14 @@ class Profile : Serializable {
private val userInfoPattern = "^(.+?):(.*)$".toRegex()
private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex()
fun findAll(data: CharSequence?) = pattern.findAll(data ?: "").map {
fun findAllUrls(data: CharSequence?, feature: Profile? = null) = pattern.findAll(data ?: "").map {
val uri = it.value.toUri()
try {
if (uri.userInfo == null) {
val match = legacyPattern.matchEntire(String(Base64.decode(uri.host, Base64.NO_PADDING)))
if (match != null) {
val profile = Profile()
feature?.copyFeatureSettingsTo(profile)
profile.method = match.groupValues[1].toLowerCase()
profile.password = match.groupValues[2]
profile.host = match.groupValues[3]
......@@ -66,6 +72,7 @@ class Profile : Serializable {
Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)))
if (match != null) {
val profile = Profile()
feature?.copyFeatureSettingsTo(profile)
profile.method = match.groupValues[1]
profile.password = match.groupValues[2]
// bug in Android: https://code.google.com/p/android/issues/detail?id=192855
......@@ -92,6 +99,54 @@ class Profile : Serializable {
null
}
}.filterNotNull()
private class JsonParser(private val feature: Profile? = null) : ArrayList<Profile>() {
private fun tryAdd(json: JSONObject) {
val host = json.optString("server")
if (host.isNullOrEmpty()) return
val remotePort = json.optInt("server_port")
if (remotePort <= 0) return
val password = json.optString("password")
if (password.isNullOrEmpty()) return
val method = json.optString("method")
if (method.isNullOrEmpty()) return
add(Profile().also {
it.host = host
it.remotePort = remotePort
it.password = password
it.method = method
}.apply {
feature?.copyFeatureSettingsTo(this)
val id = json.optString("plugin")
if (!id.isNullOrEmpty()) {
plugin = PluginOptions(id, json.optString("plugin_opts")).toString(false)
}
name = json.optString("remarks")
route = json.optString("route", route)
remoteDns = json.optString("remote_dns", remoteDns)
ipv6 = json.optBoolean("ipv6", ipv6)
json.optJSONObject("proxy_apps")?.also {
proxyApps = it.optBoolean("enabled", proxyApps)
bypass = it.optBoolean("bypass", bypass)
individual = it.optJSONArray("android_list")?.asIterable()?.joinToString("\n") ?: individual
}
udpdns = json.optBoolean("udpdns", udpdns)
})
}
fun process(json: Any) {
when (json) {
is JSONObject -> {
tryAdd(json)
for (key in json.keys()) process(json.get(key))
}
is JSONArray -> json.asIterable().forEach(this::process)
// ignore other types
}
}
}
fun parseJson(json: String, feature: Profile? = null): List<Profile> =
JsonParser(feature).apply { process(JSONTokener(json).nextValue()) }
}
@androidx.room.Dao
......@@ -143,6 +198,15 @@ class Profile : Serializable {
val formattedAddress get() = (if (host.contains(":")) "[%s]:%d" else "%s:%d").format(host, remotePort)
val formattedName get() = if (name.isNullOrEmpty()) formattedAddress else name!!
fun copyFeatureSettingsTo(profile: Profile) {
profile.route = route
profile.ipv6 = ipv6
profile.proxyApps = proxyApps
profile.bypass = bypass
profile.individual = individual
profile.udpdns = udpdns
}
fun toUri(): Uri {
val builder = Uri.Builder()
.scheme("ss")
......@@ -158,6 +222,33 @@ class Profile : Serializable {
}
override fun toString() = toUri().toString()
fun toJson(compat: Boolean = false) = JSONObject().apply {
put("server", host)
put("server_port", remotePort)
put("password", password)
put("method", method)
if (compat) return@apply
PluginConfiguration(plugin ?: "").selectedOptions.apply {
if (id.isNotEmpty()) {
put("plugin", id)
put("plugin_opts", toString())
}
}
put("remarks", name)
put("route", route)
put("remote_dns", remoteDns)
put("ipv6", ipv6)
put("proxy_apps", JSONObject().apply {
put("enabled", proxyApps)
if (proxyApps) {
put("bypass", bypass)
// android_ prefix is used because package names are Android specific
put("android_list", JSONArray(individual.split("\n")))
}
})
put("udpdns", udpdns)
}
fun serialize() {
DataStore.privateStore.putString(Key.name, name)
DataStore.privateStore.putString(Key.host, host)
......
......@@ -21,7 +21,6 @@
package com.github.shadowsocks.database
import android.database.sqlite.SQLiteCantOpenDatabaseException
import com.github.shadowsocks.App.Companion.app
import com.github.shadowsocks.ProfilesFragment
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot
......@@ -35,19 +34,8 @@ import java.sql.SQLException
*/
object ProfileManager {
@Throws(SQLException::class)
fun createProfile(p: Profile? = null): Profile {
val profile = p ?: Profile()
fun createProfile(profile: Profile = Profile()): Profile {
profile.id = 0
val oldProfile = app.currentProfile
if (oldProfile != null) {
// Copy Feature Settings from old profile
profile.route = oldProfile.route
profile.ipv6 = oldProfile.ipv6
profile.proxyApps = oldProfile.proxyApps
profile.bypass = oldProfile.bypass
profile.individual = oldProfile.individual
profile.udpdns = oldProfile.udpdns
}
profile.userOrder = PrivateDatabase.profileDao.nextOrder() ?: 0
profile.id = PrivateDatabase.profileDao.create(profile)
ProfilesFragment.instance?.profilesAdapter?.add(profile)
......
/*******************************************************************************
* *
* Copyright (C) 2018 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2018 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 android.content.ClipData
import androidx.recyclerview.widget.SortedList
import org.json.JSONArray
private sealed class ArrayIterator<out T> : Iterator<T> {
abstract val size: Int
abstract operator fun get(index: Int): T
private var count = 0
override fun hasNext() = count < size
override fun next(): T = if (hasNext()) this[count++] else throw NoSuchElementException()
}
private class ClipDataIterator(private val data: ClipData) : ArrayIterator<ClipData.Item>() {
override val size get() = data.itemCount
override fun get(index: Int) = data.getItemAt(index)
}
fun ClipData.asIterable() = Iterable { ClipDataIterator(this) }
private class JSONArrayIterator(private val arr: JSONArray) : ArrayIterator<Any>() {
override val size get() = arr.length()
override fun get(index: Int) = arr.get(index)
}
fun JSONArray.asIterable() = Iterable { JSONArrayIterator(this) }
private class SortedListIterator<out T>(private val list: SortedList<T>) : ArrayIterator<T>() {
override val size get() = list.size()
override fun get(index: Int) = list[index]
}
fun <T> SortedList<T>.asIterable() = Iterable { SortedListIterator(this) }
......@@ -12,7 +12,6 @@ import android.net.Uri
import android.os.Build
import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.recyclerview.widget.SortedList
import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.JniHelper
import java.net.InetAddress
......@@ -63,15 +62,7 @@ fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int {
return typedValue.resourceId
}
private class SortedListIterable<out T>(private val list: SortedList<T>) : Iterable<T> {
override fun iterator(): Iterator<T> = SortedListIterator(list)
}
private class SortedListIterator<out T>(private val list: SortedList<T>) : Iterator<T> {
private var count = 0
override fun hasNext() = count < list.size()
override fun next(): T = if (hasNext()) list[count++] else throw NoSuchElementException()
}
fun <T> SortedList<T>.asIterable(): Iterable<T> = SortedListIterable(this)
val Intent.datas get() = listOfNotNull(data) + (clipData?.asIterable()?.map { it.uri }?.filterNotNull() ?: emptyList())
fun printLog(t: Throwable) {
Crashlytics.logException(t)
......
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFF"
android:pathData="M9,16h6v-6h4l-7,-7 -7,7h4zM5,18h14v2L5,20z"/>
</vector>
......@@ -7,14 +7,14 @@
android:numericShortcut="1"
app:showAsAction="never"/>
<item
android:id="@+id/action_export"
android:id="@+id/action_export_clipboard"
android:alphabeticShortcut="e"
android:icon="?attr/actionModeCopyDrawable"
android:numericShortcut="2"
android:title="@string/action_export"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/action_import"
android:id="@+id/action_import_clipboard"
android:alphabeticShortcut="i"
android:icon="?attr/actionModePasteDrawable"
android:numericShortcut="3"
......
......@@ -11,7 +11,7 @@
android:title="@string/add_profile_methods_manual_settings"
android:alphabeticShortcut="m"
android:numericShortcut="1"/>
<item android:id="@+id/action_import"
<item android:id="@+id/action_import_clipboard"
android:title="@string/action_import"
android:alphabeticShortcut="i"
android:numericShortcut="2"/>
......
......@@ -14,22 +14,39 @@
android:numericShortcut="1"
android:title="@string/add_profile_methods_scan_qr_code"/>
<item
android:id="@+id/action_import"
android:alphabeticShortcut="i"
android:id="@+id/action_import_clipboard"
android:alphabeticShortcut="c"
android:numericShortcut="2"
android:title="@string/action_import"/>
<item
android:id="@+id/action_import_file"
android:alphabeticShortcut="f"
android:numericShortcut="3"
android:title="@string/action_import_file"/>
<item
android:id="@+id/action_manual_settings"
android:alphabeticShortcut="m"
android:numericShortcut="3"
android:numericShortcut="4"
android:title="@string/add_profile_methods_manual_settings"/>
</menu>
</item>
<item
android:id="@+id/action_export"
android:alphabeticShortcut="e"
android:icon="?attr/actionModeCopyDrawable"
android:icon="@drawable/ic_file_file_upload"
android:numericShortcut="1"
android:title="@string/action_export"
app:showAsAction="always"/>
android:title="@string/action_export_more"
app:showAsAction="always">
<menu>
<item
android:id="@+id/action_export_clipboard"
android:alphabeticShortcut="c"
android:numericShortcut="1"
android:title="@string/action_export"/>
<item
android:id="@+id/action_export_file"
android:alphabeticShortcut="f"
android:numericShortcut="2"
android:title="@string/action_export_file"/>
</menu>
</item>
</menu>
......@@ -4,7 +4,7 @@
android:title="@string/share_qr_nfc"
android:alphabeticShortcut="q"
android:numericShortcut="1"/>
<item android:id="@+id/action_export"
<item android:id="@+id/action_export_clipboard"
android:title="@string/action_export"
android:alphabeticShortcut="e"
android:numericShortcut="2"/>
......
<?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:id="@+id/action_import"
<item android:id="@+id/action_import_clipboard"
android:title="@string/action_import_file"
android:icon="@drawable/ic_image_photo"
android:alphabeticShortcut="p"
......
......@@ -93,6 +93,8 @@
<string name="share">Share</string>
<string name="add_profile">Add Profile</string>
<string name="action_apply_all">Apply Settings to All Profiles</string>
<string name="action_export_more">Export…</string>
<string name="action_export_file">Export to file…</string>
<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>
......
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