Skip to content

Commit f2c71a4

Browse files
committed
Added about app layout with links and update checker
1 parent 08c2a0a commit f2c71a4

32 files changed

+1657
-9
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ android {
1313
targetSdk = 36
1414
versionCode = 100
1515
versionName = "1.0"
16-
17-
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1816
}
1917

2018
buildTypes {
@@ -50,4 +48,6 @@ android {
5048

5149
dependencies {
5250
implementation(libs.androidx.appcompat)
51+
implementation(libs.androidx.fragment)
52+
implementation(libs.google.material)
5353
}

app/src/main/AndroidManifest.xml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
22
xmlns:tools="http://schemas.android.com/tools">
33

4+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5+
<uses-permission android:name="android.permission.INTERNET" />
6+
47
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
58

69
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:ignore="ForegroundServicesPolicy" />
@@ -34,10 +37,23 @@
3437
android:required="false" />
3538

3639
<application
40+
android:enableOnBackInvokedCallback="true"
3741
android:icon="@mipmap/ic_launcher"
3842
android:label="@string/app_name"
3943
android:supportsRtl="true"
40-
android:theme="@style/AppTheme">
44+
android:theme="@style/AppTheme"
45+
tools:targetApi="tiramisu">
46+
47+
<!--about application activity for tiles-->
48+
49+
<activity
50+
android:name=".activity.AboutApplicationActivity"
51+
android:exported="false"
52+
android:theme="@style/AppTheme.Transparent">
53+
<intent-filter>
54+
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
55+
</intent-filter>
56+
</activity>
4157

4258
<!--function activity for tiles-->
4359

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.wstxda.toolkit.activity
2+
3+
import android.graphics.Color
4+
import android.os.Build
5+
import android.os.Bundle
6+
import android.view.View
7+
import androidx.activity.SystemBarStyle
8+
import androidx.activity.enableEdgeToEdge
9+
import androidx.appcompat.app.AppCompatActivity
10+
import androidx.core.view.ViewCompat
11+
import androidx.core.view.WindowInsetsCompat
12+
import com.wstxda.toolkit.component.AboutApplicationBottomSheetDialog
13+
14+
class AboutApplicationActivity : AppCompatActivity() {
15+
16+
override fun onCreate(savedInstanceState: Bundle?) {
17+
super.onCreate(savedInstanceState)
18+
enableEdgeToEdgeNoContrast()
19+
applySystemBarInsets(window.decorView)
20+
21+
if (savedInstanceState == null) {
22+
AboutApplicationBottomSheetDialog().show(supportFragmentManager, "about_app")
23+
}
24+
}
25+
26+
private fun enableEdgeToEdgeNoContrast() {
27+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
28+
enableEdgeToEdge(
29+
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
30+
)
31+
window.isNavigationBarContrastEnforced = false
32+
}
33+
}
34+
35+
private fun applySystemBarInsets(target: View) {
36+
ViewCompat.setOnApplyWindowInsetsListener(target) { view, insets ->
37+
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
38+
view.setPadding(bars.left, 0, bars.right, bars.bottom)
39+
WindowInsetsCompat.CONSUMED
40+
}
41+
}
42+
43+
@Suppress("DEPRECATION")
44+
override fun onPause() {
45+
super.onPause()
46+
if (isFinishing) overridePendingTransition(0, 0)
47+
}
48+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.wstxda.toolkit.adapter
2+
3+
import android.view.LayoutInflater
4+
import android.view.ViewGroup
5+
import androidx.core.view.isVisible
6+
import androidx.core.view.updateLayoutParams
7+
import androidx.recyclerview.widget.DiffUtil
8+
import androidx.recyclerview.widget.ListAdapter
9+
import androidx.recyclerview.widget.RecyclerView
10+
import com.google.android.material.shape.ShapeAppearanceModel
11+
import com.wstxda.toolkit.data.AboutItem
12+
import com.wstxda.toolkit.databinding.ItemAboutLinkBinding
13+
import com.google.android.material.R
14+
15+
class AboutApplicationAdapter(
16+
private val onClick: (AboutItem) -> Unit
17+
) : ListAdapter<AboutItem, AboutApplicationAdapter.LinkViewHolder>(LinkDiffCallback) {
18+
19+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = LinkViewHolder(
20+
ItemAboutLinkBinding.inflate(LayoutInflater.from(parent.context), parent, false)
21+
)
22+
23+
override fun onBindViewHolder(holder: LinkViewHolder, position: Int) {
24+
holder.bind(getItem(position), position, itemCount)
25+
}
26+
27+
inner class LinkViewHolder(private val binding: ItemAboutLinkBinding) :
28+
RecyclerView.ViewHolder(binding.root) {
29+
30+
fun bind(link: AboutItem, position: Int, totalItems: Int) = with(binding) {
31+
link.title?.let { titleItem.setText(it) }
32+
titleItem.isVisible = link.title != null
33+
34+
link.icon?.let { iconItem.setImageResource(it) }
35+
iconItem.isVisible = link.icon != null
36+
37+
link.summary?.let { summaryItem.setText(it) }
38+
summaryItem.isVisible = link.summary != null
39+
40+
val isClickable = link.url != null
41+
cardItem.isClickable = isClickable
42+
cardItem.isFocusable = isClickable
43+
cardItem.setOnClickListener(
44+
if (isClickable) {
45+
{ onClick(link) }
46+
} else null)
47+
48+
applyCardStyle(position, totalItems)
49+
}
50+
51+
private fun applyCardStyle(position: Int, totalItems: Int) {
52+
val context = binding.root.context
53+
54+
val shapeStyleResId = when {
55+
totalItems == 1 -> R.style.ShapeAppearance_Material3_ListItem_Single
56+
position == 0 -> R.style.ShapeAppearance_Material3_ListItem_First
57+
position == totalItems - 1 -> R.style.ShapeAppearance_Material3_ListItem_Last
58+
else -> R.style.ShapeAppearance_Material3_ListItem_Middle
59+
}
60+
61+
binding.cardItem.shapeAppearanceModel = ShapeAppearanceModel.builder(
62+
context, shapeStyleResId, 0
63+
).build()
64+
65+
val density = context.resources.displayMetrics.density
66+
val isFirst = position == 0
67+
val isLast = position == totalItems - 1
68+
69+
val marginTop = if (isFirst) 16 else 0
70+
val marginBottom = if (isLast) 16 else 2
71+
72+
binding.cardItem.updateLayoutParams<ViewGroup.MarginLayoutParams> {
73+
topMargin = (marginTop * density).toInt()
74+
bottomMargin = (marginBottom * density).toInt()
75+
}
76+
}
77+
}
78+
79+
object LinkDiffCallback : DiffUtil.ItemCallback<AboutItem>() {
80+
override fun areItemsTheSame(oldItem: AboutItem, newItem: AboutItem) =
81+
oldItem.title == newItem.title
82+
83+
override fun areContentsTheSame(oldItem: AboutItem, newItem: AboutItem) = oldItem == newItem
84+
}
85+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.wstxda.toolkit.component
2+
3+
import android.content.DialogInterface
4+
import android.os.Bundle
5+
import android.view.LayoutInflater
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import androidx.fragment.app.viewModels
9+
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
10+
import com.wstxda.toolkit.R
11+
import com.wstxda.toolkit.adapter.AboutApplicationAdapter
12+
import com.wstxda.toolkit.databinding.BottomSheetAboutBinding
13+
import com.wstxda.toolkit.services.UpdaterService
14+
import com.wstxda.toolkit.ui.utils.Haptics
15+
import com.wstxda.toolkit.viewmodel.AboutApplicationViewModel
16+
17+
class AboutApplicationBottomSheetDialog : BottomSheetDialogFragment() {
18+
19+
private var _binding: BottomSheetAboutBinding? = null
20+
private lateinit var haptics: Haptics
21+
private val binding get() = _binding!!
22+
23+
private val viewModel: AboutApplicationViewModel by viewModels()
24+
25+
override fun onCreateView(
26+
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
27+
): View {
28+
_binding = BottomSheetAboutBinding.inflate(inflater, container, false)
29+
return binding.root
30+
}
31+
32+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
33+
super.onViewCreated(view, savedInstanceState)
34+
35+
haptics = Haptics(requireContext().applicationContext)
36+
37+
val adapter = AboutApplicationAdapter(viewModel::openUrl)
38+
binding.recyclerLinks.adapter = adapter
39+
40+
viewModel.applicationVersion.observe(viewLifecycleOwner) { version ->
41+
binding.appUpdate.text = getString(R.string.about_version, version)
42+
43+
binding.appUpdate.setOnClickListener {
44+
UpdaterService.checkForUpdates(requireContext(), it)
45+
}
46+
47+
binding.appIcon.setOnClickListener {
48+
haptics.tick()
49+
viewModel.openAppInfo()
50+
}
51+
}
52+
53+
viewModel.links.observe(viewLifecycleOwner) { links ->
54+
adapter.submitList(links)
55+
}
56+
}
57+
58+
override fun onDestroyView() {
59+
super.onDestroyView()
60+
_binding = null
61+
}
62+
63+
override fun onDismiss(dialog: DialogInterface) {
64+
super.onDismiss(dialog)
65+
activity?.finish()
66+
}
67+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.wstxda.toolkit.data
2+
3+
data class AboutItem(
4+
val title: Int? = null,
5+
val url: String? = null,
6+
val icon: Int? = null,
7+
val summary: Int? = null
8+
)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.wstxda.toolkit.services
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.net.ConnectivityManager
6+
import android.net.NetworkCapabilities
7+
import android.view.View
8+
import androidx.coordinatorlayout.widget.CoordinatorLayout
9+
import androidx.core.net.toUri
10+
import com.google.android.material.snackbar.Snackbar
11+
import com.wstxda.toolkit.R
12+
import kotlinx.coroutines.CoroutineScope
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.launch
15+
import kotlinx.coroutines.withContext
16+
import org.json.JSONObject
17+
import java.net.URL
18+
import kotlin.apply
19+
import kotlin.collections.getOrElse
20+
import kotlin.collections.map
21+
import kotlin.getOrDefault
22+
import kotlin.io.readText
23+
import kotlin.ranges.until
24+
import kotlin.runCatching
25+
import kotlin.text.removePrefix
26+
import kotlin.text.replace
27+
import kotlin.text.split
28+
import kotlin.text.toIntOrNull
29+
30+
object UpdaterService {
31+
32+
private const val GITHUB_RELEASE_URL = "https://api.github.com/repos/WSTxda/Toolkit-Tiles/releases/latest"
33+
34+
fun checkForUpdates(context: Context, anchorView: View) {
35+
CoroutineScope(Dispatchers.Main).launch {
36+
if (!isNetworkAvailable(context)) {
37+
showNoInternetSnackbar(anchorView)
38+
return@launch
39+
}
40+
41+
try {
42+
val latestVersion = withContext(Dispatchers.IO) { fetchLatestVersion() }
43+
val currentVersion = getCurrentVersion(context)
44+
if (compareVersions(currentVersion, latestVersion) < 0) {
45+
showUpdateAvailableSnackbar(anchorView, latestVersion)
46+
} else {
47+
showNoUpdateSnackbar(anchorView)
48+
}
49+
} catch (_: Exception) {
50+
showGenericErrorSnackbar(anchorView)
51+
}
52+
}
53+
}
54+
55+
private fun isNetworkAvailable(context: Context): Boolean {
56+
val connectivityManager =
57+
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
58+
?: return false
59+
val network = connectivityManager.activeNetwork ?: return false
60+
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
61+
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
62+
}
63+
64+
private suspend fun fetchLatestVersion(): String = withContext(Dispatchers.IO) {
65+
val jsonString = URL(GITHUB_RELEASE_URL).readText()
66+
val jsonObject = JSONObject(jsonString)
67+
jsonObject.optString("tag_name").removePrefix("v")
68+
}
69+
70+
private fun getCurrentVersion(context: Context): String = runCatching {
71+
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "N/A"
72+
}.getOrDefault("N/A")
73+
74+
private fun compareVersions(current: String, latest: String): Int {
75+
if (current == "N/A") return -1
76+
val currentParts = current.split(".").map { it.toIntOrNull() ?: 0 }
77+
val latestParts = latest.split(".").map { it.toIntOrNull() ?: 0 }
78+
for (i in 0 until kotlin.comparisons.maxOf(currentParts.size, latestParts.size)) {
79+
val curr = currentParts.getOrElse(i) { 0 }
80+
val late = latestParts.getOrElse(i) { 0 }
81+
if (curr != late) return curr - late
82+
}
83+
return 0
84+
}
85+
86+
private fun showNoUpdateSnackbar(anchorView: View) {
87+
Snackbar.make(anchorView, R.string.update_checker_no_update, Snackbar.LENGTH_SHORT).show()
88+
}
89+
90+
private fun showUpdateAvailableSnackbar(anchorView: View, latestVersion: String) {
91+
Snackbar.make(
92+
anchorView,
93+
anchorView.context.getString(R.string.update_checker_update_available, latestVersion),
94+
Snackbar.LENGTH_LONG
95+
).apply {
96+
setAction(R.string.update_checker_download_button) {
97+
val intent = Intent(
98+
Intent.ACTION_VIEW,
99+
GITHUB_RELEASE_URL.replace("api.", "").replace("/repos", "").toUri()
100+
)
101+
anchorView.context.startActivity(intent)
102+
}
103+
addCallback(object : Snackbar.Callback() {
104+
override fun onShown(snackBar: Snackbar) {
105+
(snackBar.view.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior = null
106+
}
107+
})
108+
}.show()
109+
}
110+
111+
private fun showNoInternetSnackbar(anchorView: View) {
112+
Snackbar.make(anchorView, R.string.update_checker_no_internet, Snackbar.LENGTH_LONG).show()
113+
}
114+
115+
private fun showGenericErrorSnackbar(anchorView: View) {
116+
Snackbar.make(anchorView, R.string.update_checker_generic_error, Snackbar.LENGTH_LONG)
117+
.show()
118+
}
119+
}

0 commit comments

Comments
 (0)