Commit fa0669df authored by Mygod's avatar Mygod

Move sync to service

parent 392f2f90
...@@ -61,6 +61,11 @@ ...@@ -61,6 +61,11 @@
android:exported="false"> android:exported="false">
</service> </service>
<service
android:name="com.github.shadowsocks.subscription.SubscriptionService"
android:exported="false">
</service>
<activity <activity
android:name="com.github.shadowsocks.UrlImportActivity" android:name="com.github.shadowsocks.UrlImportActivity"
android:theme="@style/Theme.AppCompat.Translucent" android:theme="@style/Theme.AppCompat.Translucent"
......
...@@ -42,6 +42,7 @@ import com.github.shadowsocks.database.Profile ...@@ -42,6 +42,7 @@ import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.net.TcpFastOpen import com.github.shadowsocks.net.TcpFastOpen
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.subscription.SubscriptionService
import com.github.shadowsocks.utils.* import com.github.shadowsocks.utils.*
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
...@@ -61,6 +62,7 @@ object Core { ...@@ -61,6 +62,7 @@ object Core {
lateinit var configureIntent: (Context) -> PendingIntent lateinit var configureIntent: (Context) -> PendingIntent
val activity by lazy { app.getSystemService<ActivityManager>()!! } val activity by lazy { app.getSystemService<ActivityManager>()!! }
val connectivity by lazy { app.getSystemService<ConnectivityManager>()!! } val connectivity by lazy { app.getSystemService<ConnectivityManager>()!! }
val notification by lazy { app.getSystemService<NotificationManager>()!! }
val packageInfo: PackageInfo by lazy { getPackageInfo(app.packageName) } val packageInfo: PackageInfo by lazy { getPackageInfo(app.packageName) }
val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) } val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) }
val analytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(deviceStorage) } val analytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(deviceStorage) }
...@@ -128,16 +130,16 @@ object Core { ...@@ -128,16 +130,16 @@ object Core {
fun updateNotificationChannels() { fun updateNotificationChannels() {
if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
val nm = app.getSystemService<NotificationManager>()!! notification.createNotificationChannels(listOf(
nm.createNotificationChannels(listOf(
NotificationChannel("service-vpn", app.getText(R.string.service_vpn), NotificationChannel("service-vpn", app.getText(R.string.service_vpn),
if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN
else NotificationManager.IMPORTANCE_LOW), // #1355 else NotificationManager.IMPORTANCE_LOW), // #1355
NotificationChannel("service-proxy", app.getText(R.string.service_proxy), NotificationChannel("service-proxy", app.getText(R.string.service_proxy),
NotificationManager.IMPORTANCE_LOW), NotificationManager.IMPORTANCE_LOW),
NotificationChannel("service-transproxy", app.getText(R.string.service_transproxy), NotificationChannel("service-transproxy", app.getText(R.string.service_transproxy),
NotificationManager.IMPORTANCE_LOW))) NotificationManager.IMPORTANCE_LOW),
nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good SubscriptionService.notificationChannel))
notification.deleteNotificationChannel("service-nat") // NAT mode is gone for good
} }
} }
......
...@@ -24,7 +24,9 @@ import android.database.sqlite.SQLiteCantOpenDatabaseException ...@@ -24,7 +24,9 @@ import android.database.sqlite.SQLiteCantOpenDatabaseException
import android.util.LongSparseArray 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.* import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.forEachTry
import com.github.shadowsocks.utils.printLog
import com.google.gson.JsonStreamParser import com.google.gson.JsonStreamParser
import org.json.JSONArray import org.json.JSONArray
import java.io.IOException import java.io.IOException
...@@ -52,46 +54,6 @@ object ProfileManager { ...@@ -52,46 +54,6 @@ object ProfileManager {
return profile return profile
} }
fun createProfilesFromSubscription(jsons: Sequence<InputStream>) {
val currentId = DataStore.profileId
val profiles = getAllProfiles()
val subscriptions = mutableMapOf<Pair<String?, String>, Profile>()
val toUpdate = mutableSetOf<Long>()
var feature: Profile? = null
profiles?.forEach { profile -> // preprocessing phase
if (currentId == profile.id) feature = profile
if (profile.subscription == Profile.SubscriptionStatus.UserConfigured) return@forEach
if (subscriptions.putIfAbsentCompat(profile.name to profile.formattedAddress, profile) != null) {
delProfile(profile.id)
if (currentId == profile.id) DataStore.profileId = 0
} else if (profile.subscription == Profile.SubscriptionStatus.Active) {
toUpdate.add(profile.id)
profile.subscription = Profile.SubscriptionStatus.Obsolete
}
}
jsons.asIterable().forEachTry { json ->
Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
subscriptions.computeCompat(it.name to it.formattedAddress) { _, oldProfile ->
when (oldProfile?.subscription) {
Profile.SubscriptionStatus.Active -> oldProfile // skip dup subscription
Profile.SubscriptionStatus.Obsolete -> {
oldProfile.password = it.password
oldProfile.method = it.method
oldProfile.plugin = it.plugin
oldProfile.udpFallback = it.udpFallback
oldProfile.subscription = Profile.SubscriptionStatus.Active
oldProfile
}
else -> createProfile(it.apply { subscription = Profile.SubscriptionStatus.Active })
}
}
}
}
profiles?.forEach { profile -> if (toUpdate.contains(profile.id)) updateProfile(profile) }
}
fun createProfilesFromJson(jsons: Sequence<InputStream>, replace: Boolean = false) { fun createProfilesFromJson(jsons: Sequence<InputStream>, replace: Boolean = false) {
val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
val feature = if (replace) { val feature = if (replace) {
......
/*******************************************************************************
* *
* 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 android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import com.github.shadowsocks.Core
import com.github.shadowsocks.Core.app
import com.github.shadowsocks.core.R
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.*
import com.google.gson.JsonStreamParser
import kotlinx.coroutines.*
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
class SubscriptionService : Service() {
companion object {
private const val NOTIFICATION_CHANNEL = "service-subscription"
private const val NOTIFICATION_ID = 2
private var worker: Job? = null
val idle = MutableLiveData<Boolean>(true)
val notificationChannel @RequiresApi(26) get() = NotificationChannel(NOTIFICATION_CHANNEL,
app.getText(R.string.service_subscription), NotificationManager.IMPORTANCE_LOW)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (worker == null) {
idle.value = false
worker = GlobalScope.launch {
val urls = Subscription.instance.urls
val notification = NotificationCompat.Builder(this@SubscriptionService, NOTIFICATION_CHANNEL).apply {
color = ContextCompat.getColor(this@SubscriptionService, R.color.material_primary_500)
priority = NotificationCompat.PRIORITY_LOW
setCategory(NotificationCompat.CATEGORY_PROGRESS)
setContentTitle(getString(R.string.service_subscription_working))
setOngoing(true)
setProgress(urls.size(), 0, false)
setSmallIcon(R.drawable.ic_file_cloud_download)
setWhen(0)
}
Core.notification.notify(NOTIFICATION_ID, notification.build())
var counter = 0
val workers = urls.asIterable().map { url ->
async(Dispatchers.IO) {
val tempFile = File.createTempFile("subscription-", ".json", cacheDir)
try {
(url.openConnection() as HttpURLConnection).useCancellable {
tempFile.outputStream().use { out -> inputStream.copyTo(out) }
}
tempFile
} catch (e: IOException) {
e.printStackTrace()
Toast.makeText(this@SubscriptionService, e.readableMessage, Toast.LENGTH_LONG).show()
if (!tempFile.delete()) tempFile.deleteOnExit()
null
} finally {
withContext(Dispatchers.Main) {
counter += 1
Core.notification.notify(NOTIFICATION_ID, notification.apply {
setProgress(urls.size(), counter, false)
}.build())
}
}
}
}
try {
val localJsons = workers.awaitAll()
withContext(Dispatchers.Main) {
Core.notification.notify(NOTIFICATION_ID, notification.apply {
setProgress(0, 0, true)
}.build())
createProfilesFromSubscription(localJsons.asSequence().filterNotNull().map { it.inputStream() })
}
} finally {
for (worker in workers) {
worker.cancel()
try {
worker.getCompleted()?.apply { if (!delete()) deleteOnExit() }
} catch (_: Exception) { }
}
GlobalScope.launch(Dispatchers.Main) {
Core.notification.cancel(NOTIFICATION_ID)
idle.value = true
}
check(worker != null)
worker = null
stopSelf(startId)
}
}
} else stopSelf(startId)
return START_NOT_STICKY
}
private fun createProfilesFromSubscription(jsons: Sequence<InputStream>) {
val currentId = DataStore.profileId
val profiles = ProfileManager.getAllProfiles()
val subscriptions = mutableMapOf<Pair<String?, String>, Profile>()
val toUpdate = mutableSetOf<Long>()
var feature: Profile? = null
profiles?.forEach { profile -> // preprocessing phase
if (currentId == profile.id) feature = profile
if (profile.subscription == Profile.SubscriptionStatus.UserConfigured) return@forEach
if (subscriptions.putIfAbsentCompat(profile.name to profile.formattedAddress, profile) != null) {
ProfileManager.delProfile(profile.id)
if (currentId == profile.id) DataStore.profileId = 0
} else if (profile.subscription == Profile.SubscriptionStatus.Active) {
toUpdate.add(profile.id)
profile.subscription = Profile.SubscriptionStatus.Obsolete
}
}
for (json in jsons.asIterable()) try {
Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
subscriptions.computeCompat(it.name to it.formattedAddress) { _, oldProfile ->
when (oldProfile?.subscription) {
Profile.SubscriptionStatus.Active -> oldProfile // skip dup subscription
Profile.SubscriptionStatus.Obsolete -> {
oldProfile.password = it.password
oldProfile.method = it.method
oldProfile.plugin = it.plugin
oldProfile.udpFallback = it.udpFallback
oldProfile.subscription = Profile.SubscriptionStatus.Active
oldProfile
}
else -> ProfileManager.createProfile(it.apply {
subscription = Profile.SubscriptionStatus.Active
})
}
}
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, e.readableMessage, Toast.LENGTH_LONG).show()
}
profiles?.forEach { profile -> if (toUpdate.contains(profile.id)) ProfileManager.updateProfile(profile) }
}
override fun onDestroy() {
worker?.cancel()
super.onDestroy()
}
}
...@@ -140,6 +140,9 @@ ...@@ -140,6 +140,9 @@
<string name="add_subscription">Add a subscription</string> <string name="add_subscription">Add a subscription</string>
<string name="edit_subscription">Edit subscription</string> <string name="edit_subscription">Edit subscription</string>
<string name="update_subscription">Refresh servers from subscription</string> <string name="update_subscription">Refresh servers from subscription</string>
<string name="service_subscription">Subscription Service</string>
<string name="service_subscription_working">Syncing subscriptions… (%d of %d)</string>
<string name="service_subscription_finishing">Finishing up…</string>
<!-- acl --> <!-- acl -->
<string name="custom_rules">Custom rules</string> <string name="custom_rules">Custom rules</string>
......
...@@ -70,7 +70,6 @@ dependencies { ...@@ -70,7 +70,6 @@ dependencies {
implementation 'com.twofortyfouram:android-plugin-api-for-locale:1.0.4' implementation 'com.twofortyfouram:android-plugin-api-for-locale:1.0.4'
implementation 'me.zhanghai.android.fastscroll:library:1.1.0' implementation 'me.zhanghai.android.fastscroll:library:1.1.0'
implementation 'xyz.belvi.mobilevision:barcodescanner:2.0.3' implementation 'xyz.belvi.mobilevision:barcodescanner:2.0.3'
implementation 'me.zhanghai.android.materialprogressbar:library:1.6.1'
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test:runner:$androidTestVersion" androidTestImplementation "androidx.test:runner:$androidTestVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion"
......
...@@ -33,6 +33,7 @@ import android.widget.EditText ...@@ -33,6 +33,7 @@ import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.observe
import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
...@@ -41,22 +42,14 @@ import com.github.shadowsocks.MainActivity ...@@ -41,22 +42,14 @@ import com.github.shadowsocks.MainActivity
import com.github.shadowsocks.R import com.github.shadowsocks.R
import com.github.shadowsocks.ToolbarFragment import com.github.shadowsocks.ToolbarFragment
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.AlertDialogFragment import com.github.shadowsocks.plugin.AlertDialogFragment
import com.github.shadowsocks.utils.asIterable
import com.github.shadowsocks.utils.readableMessage import com.github.shadowsocks.utils.readableMessage
import com.github.shadowsocks.utils.useCancellable
import com.github.shadowsocks.widget.ListHolderListener import com.github.shadowsocks.widget.ListHolderListener
import com.github.shadowsocks.widget.MainListListener import com.github.shadowsocks.widget.MainListListener
import com.github.shadowsocks.widget.UndoSnackbarManager import com.github.shadowsocks.widget.UndoSnackbarManager
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import me.zhanghai.android.fastscroll.FastScrollerBuilder import me.zhanghai.android.fastscroll.FastScrollerBuilder
import me.zhanghai.android.materialprogressbar.MaterialProgressBar
import java.net.HttpURLConnection
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.URL import java.net.URL
...@@ -218,42 +211,8 @@ class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener ...@@ -218,42 +211,8 @@ class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener
private val adapter by lazy { SubscriptionAdapter() } private val adapter by lazy { SubscriptionAdapter() }
private lateinit var list: RecyclerView private lateinit var list: RecyclerView
private lateinit var progress: MaterialProgressBar
private var mode: ActionMode? = null private var mode: ActionMode? = null
private lateinit var undoManager: UndoSnackbarManager<Any> private lateinit var undoManager: UndoSnackbarManager<URL>
private var fetchJob: Job? = null
private fun fetchServerFromSubscriptions() {
if (fetchJob?.isActive != true) {
val activity = activity as MainActivity
progress.visibility = View.VISIBLE
fetchJob = GlobalScope.launch {
val subscription = Subscription.instance
for (url in subscription.urls.asIterable()) {
try {
val connection = url.openConnection() as HttpURLConnection
connection.useCancellable {
ProfileManager.createProfilesFromSubscription(sequenceOf(connection.inputStream))
}
} catch (e: Exception) {
e.printStackTrace()
activity.snackbar(e.readableMessage).show()
}
}
progress.post {
progress.visibility = View.INVISIBLE
}
}
}
}
override fun onPause() {
fetchJob?.cancel()
super.onPause()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.layout_subscriptions, container, false) inflater.inflate(R.layout.layout_subscriptions, container, false)
...@@ -264,13 +223,15 @@ class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener ...@@ -264,13 +223,15 @@ class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener
toolbar.setTitle(R.string.subscriptions) toolbar.setTitle(R.string.subscriptions)
toolbar.inflateMenu(R.menu.subscription_menu) toolbar.inflateMenu(R.menu.subscription_menu)
toolbar.setOnMenuItemClickListener(this) toolbar.setOnMenuItemClickListener(this)
SubscriptionService.idle.observe(this) {
toolbar.menu.findItem(R.id.action_update_subscription).isEnabled = it
}
val activity = activity as MainActivity val activity = activity as MainActivity
list = view.findViewById(R.id.list) list = view.findViewById(R.id.list)
list.setOnApplyWindowInsetsListener(MainListListener) list.setOnApplyWindowInsetsListener(MainListListener)
list.layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false) list.layoutManager = LinearLayoutManager(activity, RecyclerView.VERTICAL, false)
list.itemAnimator = DefaultItemAnimator() list.itemAnimator = DefaultItemAnimator()
list.adapter = adapter list.adapter = adapter
progress = view.findViewById(R.id.indeterminate_horizontal_progress)
FastScrollerBuilder(list).useMd2Style().build() FastScrollerBuilder(list).useMd2Style().build()
undoManager = UndoSnackbarManager(activity, adapter::undo) undoManager = UndoSnackbarManager(activity, adapter::undo)
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.START or ItemTouchHelper.END) { ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.START or ItemTouchHelper.END) {
...@@ -299,7 +260,8 @@ class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener ...@@ -299,7 +260,8 @@ class SubscriptionFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener
true true
} }
R.id.action_update_subscription -> { R.id.action_update_subscription -> {
fetchServerFromSubscriptions() val context = requireContext()
context.startService(Intent(context, SubscriptionService::class.java))
true true
} }
else -> false else -> false
......
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
android:focusable="true" android:focusable="true"
android:padding="12dp" android:padding="12dp"
android:visibility="gone" android:visibility="gone"
app:srcCompat="@drawable/ic_cloud_queue"/> app:srcCompat="@drawable/ic_file_cloud_queue"/>
<androidx.appcompat.widget.AppCompatImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/edit" android:id="@+id/edit"
......
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
...@@ -9,18 +8,6 @@ ...@@ -9,18 +8,6 @@
<include layout="@layout/toolbar_light_dark" /> <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 <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
android:icon="@drawable/ic_action_description"/> android:icon="@drawable/ic_action_description"/>
<item android:id="@+id/subscriptions" <item android:id="@+id/subscriptions"
android:title="@string/subscriptions" android:title="@string/subscriptions"
android:icon="@drawable/ic_action_cloud_download"/> android:icon="@drawable/ic_file_cloud_download"/>
<item android:id="@+id/customRules" <item android:id="@+id/customRules"
android:title="@string/custom_rules" android:title="@string/custom_rules"
android:icon="@drawable/ic_action_assignment"/> android:icon="@drawable/ic_action_assignment"/>
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
app:showAsAction="always"/> app:showAsAction="always"/>
<item android:title="@string/update_subscription" <item android:title="@string/update_subscription"
android:id="@+id/action_update_subscription" android:id="@+id/action_update_subscription"
android:icon="@drawable/ic_action_cloud_download" android:icon="@drawable/ic_file_cloud_download"
android:alphabeticShortcut="r" android:alphabeticShortcut="r"
app:showAsAction="ifRoom"/> app:showAsAction="ifRoom"/>
</menu> </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