Add support for checking app updates
This commit is contained in:
@@ -93,6 +93,8 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4")
|
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
|
||||||
add("googleImplementation", "com.github.tiann:FreeReflection:3.1.0")
|
add("googleImplementation", "com.github.tiann:FreeReflection:3.1.0")
|
||||||
|
add("googleImplementation", "com.google.android.play:core:1.10.2")
|
||||||
|
add("googleImplementation", "com.google.android.play:core-ktx:1.8.1")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.room:room-testing:$roomVersion")
|
androidTestImplementation("androidx.room:room-testing:$roomVersion")
|
||||||
androidTestImplementation("androidx.test:runner:1.4.0")
|
androidTestImplementation("androidx.test:runner:1.4.0")
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package be.mygod.vpnhotspot.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import be.mygod.vpnhotspot.App.Companion.app
|
||||||
|
import be.mygod.vpnhotspot.BuildConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
object UpdateChecker {
|
||||||
|
private const val KEY_LAST_FETCHED = "update.lastFetched"
|
||||||
|
private const val KEY_VERSION = "update.version"
|
||||||
|
private const val KEY_PUBLISHED = "update.published"
|
||||||
|
private const val UPDATE_INTERVAL = 1000 * 60 * 60 * 6
|
||||||
|
|
||||||
|
private class GitHubUpdate(override val message: String, private val published: Long) : AppUpdate {
|
||||||
|
override val stalenessDays get() = max(0,
|
||||||
|
TimeUnit.DAYS.convert(System.currentTimeMillis() - published, TimeUnit.MILLISECONDS)).toInt()
|
||||||
|
|
||||||
|
override fun updateForResult(activity: Activity, requestCode: Int) {
|
||||||
|
app.customTabsIntent.launchUrl(activity, Uri.parse("https://github.com/Mygod/VPNHotspot/releases"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun check() = flow<AppUpdate?> {
|
||||||
|
val myVersion = "v${BuildConfig.VERSION_NAME}"
|
||||||
|
emit(app.pref.getString(KEY_VERSION, null)?.let {
|
||||||
|
if (myVersion == it) null else GitHubUpdate(it, app.pref.getLong(KEY_PUBLISHED, -1))
|
||||||
|
})
|
||||||
|
while (true) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastFetched = app.pref.getLong(KEY_LAST_FETCHED, -1)
|
||||||
|
if (lastFetched in 0..now) delay(lastFetched + UPDATE_INTERVAL - now)
|
||||||
|
val conn = URL("https://api.github.com/repos/Mygod/VPNHotspot/releases/latest")
|
||||||
|
.openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.setRequestProperty("Accept", "application/vnd.github.v3+json")
|
||||||
|
val response = JSONObject(withContext(Dispatchers.IO) {
|
||||||
|
conn.inputStream.bufferedReader().readText()
|
||||||
|
})
|
||||||
|
val version = response.getString("name")
|
||||||
|
val published = Instant.parse(response.getString("published_at")).toEpochMilli()
|
||||||
|
app.pref.edit {
|
||||||
|
putLong(KEY_LAST_FETCHED, System.currentTimeMillis())
|
||||||
|
putString(KEY_VERSION, version)
|
||||||
|
putLong(KEY_PUBLISHED, published)
|
||||||
|
}
|
||||||
|
emit(if (myVersion == version) null else GitHubUpdate(version, published))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e)
|
||||||
|
} finally {
|
||||||
|
conn.disconnectCompat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package be.mygod.vpnhotspot.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.net.Uri
|
||||||
|
import be.mygod.vpnhotspot.App.Companion.app
|
||||||
|
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||||
|
import com.google.android.play.core.install.model.InstallErrorCode
|
||||||
|
import com.google.android.play.core.install.model.InstallStatus
|
||||||
|
import com.google.android.play.core.ktx.AppUpdateResult
|
||||||
|
import com.google.android.play.core.ktx.requestUpdateFlow
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
object UpdateChecker {
|
||||||
|
private class UpdateAvailable(private val update: AppUpdateResult.Available) : AppUpdate {
|
||||||
|
override val stalenessDays get() = update.updateInfo.clientVersionStalenessDays() ?: 0
|
||||||
|
override fun updateForResult(activity: Activity, requestCode: Int) = try {
|
||||||
|
check(update.startFlexibleUpdate(activity, requestCode)) { "startFlexibleUpdate failed" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e)
|
||||||
|
app.customTabsIntent.launchUrl(activity,
|
||||||
|
Uri.parse("https://play.google.com/store/apps/details?id=be.mygod.vpnhotspot"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private class UpdateDownloading(private val update: AppUpdateResult.InProgress) : AppUpdate {
|
||||||
|
override val downloaded get() = false
|
||||||
|
override val message: String? get() {
|
||||||
|
if (update.installState.installStatus() != InstallStatus.FAILED) return null
|
||||||
|
val code = update.installState.installErrorCode()
|
||||||
|
for (f in InstallErrorCode::class.java.declaredFields) if (f.getInt(null) == code) return f.name
|
||||||
|
return "Unrecognized Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private class UpdateDownloaded(private val update: AppUpdateResult.Downloaded) : AppUpdate {
|
||||||
|
override val downloaded get() = true
|
||||||
|
override val stalenessDays get() = 0
|
||||||
|
override fun updateForResult(activity: Activity, requestCode: Int) {
|
||||||
|
GlobalScope.launch { update.completeUpdate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val manager by lazy { AppUpdateManagerFactory.create(app) }
|
||||||
|
|
||||||
|
fun check() = manager.requestUpdateFlow().map { result ->
|
||||||
|
when (result) {
|
||||||
|
is AppUpdateResult.NotAvailable -> null
|
||||||
|
is AppUpdateResult.Available -> UpdateAvailable(result)
|
||||||
|
is AppUpdateResult.InProgress -> {
|
||||||
|
if (result.installState.installStatus() == InstallStatus.CANCELED) null else UpdateDownloading(result)
|
||||||
|
}
|
||||||
|
is AppUpdateResult.Downloaded -> UpdateDownloaded(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,26 +6,46 @@ import androidx.activity.viewModels
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import be.mygod.vpnhotspot.client.ClientViewModel
|
import be.mygod.vpnhotspot.client.ClientViewModel
|
||||||
import be.mygod.vpnhotspot.client.ClientsFragment
|
import be.mygod.vpnhotspot.client.ClientsFragment
|
||||||
import be.mygod.vpnhotspot.databinding.ActivityMainBinding
|
import be.mygod.vpnhotspot.databinding.ActivityMainBinding
|
||||||
import be.mygod.vpnhotspot.manage.TetheringFragment
|
import be.mygod.vpnhotspot.manage.TetheringFragment
|
||||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||||
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
||||||
|
import be.mygod.vpnhotspot.util.AppUpdate
|
||||||
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
|
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
|
||||||
import be.mygod.vpnhotspot.util.Services
|
import be.mygod.vpnhotspot.util.Services
|
||||||
|
import be.mygod.vpnhotspot.util.UpdateChecker
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
|
import com.google.android.material.badge.BadgeDrawable
|
||||||
import com.google.android.material.navigation.NavigationBarView
|
import com.google.android.material.navigation.NavigationBarView
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
|
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
|
||||||
lateinit var binding: ActivityMainBinding
|
lateinit var binding: ActivityMainBinding
|
||||||
|
private lateinit var updateItem: MenuItem
|
||||||
|
private lateinit var updateBadge: BadgeDrawable
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
binding.navigation.setOnItemSelectedListener(this)
|
binding.navigation.setOnItemSelectedListener(this)
|
||||||
|
val badge = binding.navigation.getOrCreateBadge(R.id.navigation_clients).apply {
|
||||||
|
backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary)
|
||||||
|
badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light)
|
||||||
|
}
|
||||||
|
updateItem = binding.navigation.menu.findItem(R.id.navigation_update)
|
||||||
|
updateItem.isCheckable = false
|
||||||
|
updateBadge = binding.navigation.getOrCreateBadge(R.id.navigation_update).apply {
|
||||||
|
backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary)
|
||||||
|
badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light)
|
||||||
|
}
|
||||||
if (savedInstanceState == null) displayFragment(TetheringFragment())
|
if (savedInstanceState == null) displayFragment(TetheringFragment())
|
||||||
val model by viewModels<ClientViewModel>()
|
val model by viewModels<ClientViewModel>()
|
||||||
lifecycle.addObserver(model)
|
lifecycle.addObserver(model)
|
||||||
@@ -34,38 +54,57 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
val count = clients.count {
|
val count = clients.count {
|
||||||
it.ip.any { (ip, state) -> ip is Inet4Address && state == IpNeighbour.State.VALID }
|
it.ip.any { (ip, state) -> ip is Inet4Address && state == IpNeighbour.State.VALID }
|
||||||
}
|
}
|
||||||
if (count > 0) binding.navigation.getOrCreateBadge(R.id.navigation_clients).apply {
|
badge.isVisible = count > 0
|
||||||
backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary)
|
badge.number = count
|
||||||
badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light)
|
|
||||||
number = count
|
|
||||||
} else binding.navigation.removeBadge(R.id.navigation_clients)
|
|
||||||
}
|
}
|
||||||
SmartSnackbar.Register(binding.fragmentHolder)
|
SmartSnackbar.Register(binding.fragmentHolder)
|
||||||
WifiDoubleLock.ActivityListener(this)
|
WifiDoubleLock.ActivityListener(this)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
onAppUpdateAvailable(null)
|
||||||
|
UpdateChecker.check().collect(this@MainActivity::onAppUpdateAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastUpdate: AppUpdate? = null
|
||||||
|
private fun onAppUpdateAvailable(update: AppUpdate?) {
|
||||||
|
lastUpdate = update
|
||||||
|
updateItem.isVisible = update != null
|
||||||
|
if (update == null) return
|
||||||
|
updateItem.isEnabled = update.downloaded != false
|
||||||
|
updateItem.setIcon(when (update.downloaded) {
|
||||||
|
null -> R.drawable.ic_action_update
|
||||||
|
false -> R.drawable.ic_file_downloading
|
||||||
|
true -> R.drawable.ic_action_autorenew
|
||||||
|
})
|
||||||
|
updateItem.title = update.message ?: "Update"
|
||||||
|
updateBadge.isVisible = when (val days = update.stalenessDays) {
|
||||||
|
null -> false
|
||||||
|
else -> {
|
||||||
|
if (days > 0) updateBadge.number = days else updateBadge.clearNumber()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) {
|
override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
R.id.navigation_clients -> {
|
R.id.navigation_clients -> {
|
||||||
if (!item.isChecked) {
|
displayFragment(ClientsFragment())
|
||||||
item.isChecked = true
|
|
||||||
displayFragment(ClientsFragment())
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_tethering -> {
|
R.id.navigation_tethering -> {
|
||||||
if (!item.isChecked) {
|
displayFragment(TetheringFragment())
|
||||||
item.isChecked = true
|
|
||||||
displayFragment(TetheringFragment())
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.navigation_settings -> {
|
R.id.navigation_settings -> {
|
||||||
if (!item.isChecked) {
|
displayFragment(SettingsPreferenceFragment())
|
||||||
item.isChecked = true
|
|
||||||
displayFragment(SettingsPreferenceFragment())
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.navigation_update -> {
|
||||||
|
lastUpdate!!.updateForResult(this, 1)
|
||||||
|
false
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import be.mygod.vpnhotspot.App.Companion.app
|
|||||||
import be.mygod.vpnhotspot.R
|
import be.mygod.vpnhotspot.R
|
||||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||||
import be.mygod.vpnhotspot.room.AppDatabase
|
import be.mygod.vpnhotspot.room.AppDatabase
|
||||||
|
import be.mygod.vpnhotspot.util.disconnectCompat
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@@ -36,7 +37,7 @@ object MacLookup {
|
|||||||
@MainThread
|
@MainThread
|
||||||
fun abort(mac: MacAddressCompat) = macLookupBusy.remove(mac)?.let { (conn, job) ->
|
fun abort(mac: MacAddressCompat) = macLookupBusy.remove(mac)?.let { (conn, job) ->
|
||||||
job.cancel()
|
job.cancel()
|
||||||
if (Build.VERSION.SDK_INT < 26) GlobalScope.launch(Dispatchers.IO) { conn.disconnect() } else conn.disconnect()
|
conn.disconnectCompat()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
|
|||||||
10
mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt
Normal file
10
mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package be.mygod.vpnhotspot.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
|
||||||
|
interface AppUpdate {
|
||||||
|
val downloaded: Boolean? get() = null
|
||||||
|
val message: String? get() = null
|
||||||
|
val stalenessDays: Int? get() = null
|
||||||
|
fun updateForResult(activity: Activity, requestCode: Int): Unit = error("Update not supported")
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ import androidx.fragment.app.FragmentManager
|
|||||||
import be.mygod.vpnhotspot.App.Companion.app
|
import be.mygod.vpnhotspot.App.Companion.app
|
||||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
@@ -32,6 +35,7 @@ import java.lang.invoke.MethodHandles
|
|||||||
import java.lang.reflect.InvocationHandler
|
import java.lang.reflect.InvocationHandler
|
||||||
import java.lang.reflect.InvocationTargetException
|
import java.lang.reflect.InvocationTargetException
|
||||||
import java.lang.reflect.Method
|
import java.lang.reflect.Method
|
||||||
|
import java.net.HttpURLConnection
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.net.SocketException
|
import java.net.SocketException
|
||||||
@@ -66,6 +70,10 @@ fun Method.matchesCompat(name: String, args: Array<out Any?>?, vararg classes: C
|
|||||||
}
|
}
|
||||||
} else matches(name, *classes)
|
} else matches(name, *classes)
|
||||||
|
|
||||||
|
fun HttpURLConnection.disconnectCompat() {
|
||||||
|
if (Build.VERSION.SDK_INT < 26) GlobalScope.launch(Dispatchers.IO) { disconnect() } else disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
fun Context.ensureReceiverUnregistered(receiver: BroadcastReceiver) {
|
fun Context.ensureReceiverUnregistered(receiver: BroadcastReceiver) {
|
||||||
try {
|
try {
|
||||||
unregisterReceiver(receiver)
|
unregisterReceiver(receiver)
|
||||||
|
|||||||
10
mobile/src/main/res/drawable/ic_action_update.xml
Normal file
10
mobile/src/main/res/drawable/ic_action_update.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M21,10.12h-6.78l2.74,-2.82c-2.73,-2.7 -7.15,-2.8 -9.88,-0.1c-2.73,2.71 -2.73,7.08 0,9.79s7.15,2.71 9.88,0C18.32,15.65 19,14.08 19,12.1h2c0,1.98 -0.88,4.55 -2.64,6.29c-3.51,3.48 -9.21,3.48 -12.72,0c-3.5,-3.47 -3.53,-9.11 -0.02,-12.58s9.14,-3.47 12.65,0L21,3V10.12zM12.5,8v4.25l3.5,2.08l-0.72,1.21L11,13V8H12.5z"/>
|
||||||
|
</vector>
|
||||||
10
mobile/src/main/res/drawable/ic_file_downloading.xml
Normal file
10
mobile/src/main/res/drawable/ic_file_downloading.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18.32,4.26C16.84,3.05 15.01,2.25 13,2.05v2.02c1.46,0.18 2.79,0.76 3.9,1.62L18.32,4.26zM19.93,11h2.02c-0.2,-2.01 -1,-3.84 -2.21,-5.32L18.31,7.1C19.17,8.21 19.75,9.54 19.93,11zM18.31,16.9l1.43,1.43c1.21,-1.48 2.01,-3.32 2.21,-5.32h-2.02C19.75,14.46 19.17,15.79 18.31,16.9zM13,19.93v2.02c2.01,-0.2 3.84,-1 5.32,-2.21l-1.43,-1.43C15.79,19.17 14.46,19.75 13,19.93zM13,12V7h-2v5H7l5,5l5,-5H13zM11,19.93v2.02c-5.05,-0.5 -9,-4.76 -9,-9.95s3.95,-9.45 9,-9.95v2.02C7.05,4.56 4,7.92 4,12S7.05,19.44 11,19.93z"/>
|
||||||
|
</vector>
|
||||||
@@ -16,4 +16,10 @@
|
|||||||
android:icon="@drawable/ic_action_settings"
|
android:icon="@drawable/ic_action_settings"
|
||||||
android:title="@string/title_settings"/>
|
android:title="@string/title_settings"/>
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_update"
|
||||||
|
android:icon="@drawable/ic_action_update"
|
||||||
|
android:title="Update"
|
||||||
|
android:visible="false"/>
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
Reference in New Issue
Block a user