Commit 912eac6c authored by Mygod's avatar Mygod

Add import/export to JSON

Fix #1761.
parent d6373926
......@@ -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,15 @@ 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
import java.io.IOException
class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
companion object {
......@@ -62,6 +67,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 +217,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 +370,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 +385,19 @@ 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: IOException) {
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 android.util.SparseArray
import android.view.MenuItem
import android.widget.Toast
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 {
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) {
......
......@@ -50,7 +50,6 @@ import com.google.firebase.analytics.FirebaseAnalytics
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.net.InetAddress
......@@ -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,13 +26,19 @@ import android.util.Base64
import android.util.Log
import androidx.core.net.toUri
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
import java.util.*
import kotlin.collections.ArrayList
@Entity
class Profile : Serializable {
......@@ -43,13 +49,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 +73,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 +100,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 +199,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 +223,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