Skip to content

Commit cd6445f

Browse files
committed
Android GUI: Migrate Rx Java to Kotlin Coroutines
1 parent fabd16d commit cd6445f

7 files changed

Lines changed: 210 additions & 204 deletions

File tree

Source/GUI/Android/app/build.gradle

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ tasks.register('copyLocales', DefaultTask) {
202202
preBuild.dependsOn copyLocales
203203

204204
dependencies {
205-
implementation 'com.google.android.material:material:1.13.0'
205+
implementation 'com.google.android.material:material:1.13.0' // can only upgrade when minSdkVersion >= 23
206206
implementation 'androidx.appcompat:appcompat:1.7.1'
207207
implementation "androidx.core:core-ktx:1.17.0" // can only upgrade when minSdkVersion >= 23
208208
implementation 'androidx.documentfile:documentfile:1.1.0'
@@ -215,11 +215,8 @@ dependencies {
215215
// Android Room
216216
// can only upgrade when minSdkVersion >= 23
217217
implementation 'androidx.room:room-runtime:2.7.2'
218-
implementation 'androidx.room:room-rxjava2:2.7.2'
218+
implementation 'androidx.room:room-ktx:2.7.2'
219219
ksp 'androidx.room:room-compiler:2.7.2'
220-
//RxJava
221-
implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
222-
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
223220
// Android Lifecycle
224221
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
225222
ksp "androidx.lifecycle:lifecycle-common-java8:2.10.0"
@@ -228,9 +225,7 @@ dependencies {
228225
implementation "com.android.billingclient:billing:8.0.0"
229226
implementation "com.android.billingclient:billing-ktx:8.0.0"
230227
// KotlinX Coroutines
231-
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"
232-
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
233-
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.10.2"
228+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0"
234229
// Locales manager
235230
implementation 'com.github.YarikSOffice:lingver:1.3.0'
236231
}

Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/PagerAdapter.kt

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,59 @@
66

77
package net.mediaarea.mediainfo
88

9-
import android.os.Bundle
9+
// Generated with Google Gemini 3.5 Flash
1010

11+
import android.os.Bundle
1112
import androidx.fragment.app.Fragment
1213
import androidx.fragment.app.FragmentActivity
14+
import androidx.recyclerview.widget.DiffUtil
1315
import androidx.viewpager2.adapter.FragmentStateAdapter
1416

15-
class PagerAdapter(fa: FragmentActivity, private var reports: List<Report>) : FragmentStateAdapter(fa) {
17+
class PagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
18+
19+
private var reports: List<Report> = emptyList()
20+
21+
// Explicitly handles item identification across updates
22+
override fun getItemId(position: Int): Long {
23+
return reports[position].id.toLong() // Critical for stable fragment re-use
24+
}
25+
26+
// Ensures ViewPager2 knows if an item moved or changed
27+
override fun containsItem(itemId: Long): Boolean {
28+
return reports.any { it.id.toLong() == itemId }
29+
}
30+
31+
// This creates our custom, structurally sound submitList
32+
fun submitList(newReports: List<Report>) {
33+
val oldReports = this.reports
34+
35+
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
36+
override fun getOldListSize(): Int = oldReports.size
37+
override fun getNewListSize(): Int = newReports.size
38+
39+
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
40+
return oldReports[oldItemPosition].id == newReports[newItemPosition].id
41+
}
42+
43+
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
44+
// Since Report is a data class, this structurally compares all properties
45+
return oldReports[oldItemPosition] == newReports[newItemPosition]
46+
}
47+
})
48+
49+
this.reports = newReports
50+
diffResult.dispatchUpdatesTo(this) // Animates and updates changes beautifully!
51+
}
1652

1753
override fun createFragment(position: Int): Fragment {
1854
return ReportDetailFragment().apply {
1955
arguments = Bundle().apply {
20-
putInt(Core.ARG_REPORT_ID, reports.elementAt(position).id)
56+
putInt(Core.ARG_REPORT_ID, reports[position].id)
2157
}
2258
}
2359
}
2460

2561
override fun getItemCount(): Int {
26-
return reports.count()
62+
return reports.size
2763
}
28-
2964
}

Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportDao.kt

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,37 @@
77
package net.mediaarea.mediainfo
88

99
import androidx.room.Dao
10-
import androidx.room.Query
11-
import androidx.room.Insert
12-
import androidx.room.Update
1310
import androidx.room.Delete
11+
import androidx.room.Insert
1412
import androidx.room.OnConflictStrategy
15-
16-
import io.reactivex.Flowable
17-
import io.reactivex.Single
13+
import androidx.room.Query
14+
import androidx.room.Update
15+
import kotlinx.coroutines.flow.Flow
1816

1917
@Dao
2018
interface ReportDao {
19+
2120
@Query("SELECT MAX(id) FROM reports")
22-
fun getLastId(): Single<Int>
21+
suspend fun getLastId(): Int?
2322

2423
@Query("SELECT * FROM reports WHERE id = :id")
25-
fun getReport(id: Int): Single<Report>
24+
suspend fun getReport(id: Int): Report?
2625

2726
@Query("SELECT * FROM reports ORDER BY id")
28-
fun getAllReports(): Flowable<List<Report>>
27+
fun getAllReports(): Flow<List<Report>>
2928

3029
@Insert(onConflict = OnConflictStrategy.REPLACE)
31-
fun insertReport(report: Report)
30+
suspend fun insertReport(report: Report): Long
3231

3332
@Update(onConflict = OnConflictStrategy.REPLACE)
34-
fun updateReport(report: Report)
33+
suspend fun updateReport(report: Report)
3534

3635
@Delete
37-
fun deleteReport(report: Report)
36+
suspend fun deleteReport(report: Report)
3837

3938
@Query("DELETE FROM reports WHERE id = :id")
40-
fun deleteReport(id: Int)
39+
suspend fun deleteReport(id: Int)
4140

4241
@Query("DELETE FROM reports")
43-
fun deleteAllReports()
42+
suspend fun deleteAllReports()
4443
}

Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportDetailActivity.kt

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,21 @@ import android.content.Intent
1313
import android.os.Bundle
1414
import android.os.Build
1515

16-
import android.view.MenuItem
17-
1816
import android.content.res.AssetManager
17+
import android.view.MenuItem
1918
import android.view.View
2019
import androidx.core.view.ViewCompat
2120
import androidx.core.view.WindowCompat
2221
import androidx.core.view.WindowInsetsCompat
2322
import androidx.core.view.updatePadding
24-
23+
import androidx.lifecycle.Lifecycle
24+
import androidx.lifecycle.lifecycleScope
25+
import androidx.lifecycle.repeatOnLifecycle
2526
import androidx.viewpager2.widget.ViewPager2
2627

27-
import io.reactivex.disposables.CompositeDisposable
28-
import io.reactivex.schedulers.Schedulers
29-
import io.reactivex.android.schedulers.AndroidSchedulers
28+
import kotlinx.coroutines.flow.collectLatest
29+
import kotlinx.coroutines.launch
30+
3031
import net.mediaarea.mediainfo.databinding.ActivityReportDetailBinding
3132

3233
class ReportDetailActivity : AppCompatActivity(), ReportActivityListener {
@@ -40,7 +41,6 @@ class ReportDetailActivity : AppCompatActivity(), ReportActivityListener {
4041

4142
}
4243
}
43-
private val disposable: CompositeDisposable = CompositeDisposable()
4444
private lateinit var reportModel: ReportViewModel
4545

4646
override fun getReportViewModel(): ReportViewModel {
@@ -96,28 +96,47 @@ class ReportDetailActivity : AppCompatActivity(), ReportActivityListener {
9696
val viewModelFactory = Injection.provideViewModelFactory(this)
9797
reportModel = ViewModelProvider(this, viewModelFactory)[ReportViewModel::class.java]
9898

99-
disposable.add(reportModel.getAllReports()
100-
.subscribeOn(Schedulers.io())
101-
.observeOn(AndroidSchedulers.mainThread())
102-
.subscribe { reports: List<Report> ->
103-
activityReportDetailBinding.pager.registerOnPageChangeCallback(PageChangeListener(reports))
104-
activityReportDetailBinding.pager.adapter = PagerAdapter(this, reports)
105-
val id = intent.getIntExtra(Core.ARG_REPORT_ID, -1)
106-
if (id!=-1) {
107-
val index = reports.indexOfFirst { it.id == id }
108-
if (index!=-1) {
109-
title = reports.elementAt(index).filename
110-
activityReportDetailBinding.pager.setCurrentItem(index, false)
99+
// From Google Gemini 3.5 Flash
100+
// 1. Initialize the adapter ONCE
101+
val pagerAdapter = PagerAdapter(this)
102+
activityReportDetailBinding.pager.adapter = pagerAdapter
103+
// 2. Track your active listener reference locally
104+
var pageChangeListener: ViewPager2.OnPageChangeCallback? = null
105+
// 3. Collect data changes safely inside the single lifecycle coroutine
106+
lifecycleScope.launch {
107+
repeatOnLifecycle(Lifecycle.State.STARTED) {
108+
// We use a flag to track if we have already run the initial intent-navigation jump
109+
var isInitialSetupDone = false
110+
reportModel.getAllReports().collectLatest { reports: List<Report> ->
111+
// A. Update the listener with the fresh data list safely without adding a new callback instance
112+
pageChangeListener?.let { oldListener ->
113+
activityReportDetailBinding.pager.unregisterOnPageChangeCallback(oldListener)
114+
}
115+
pageChangeListener = PageChangeListener(reports)
116+
activityReportDetailBinding.pager.registerOnPageChangeCallback(pageChangeListener)
117+
// B. Push the new items cleanly into your existing adapter
118+
pagerAdapter.submitList(reports)
119+
// C. Handle the intent navigation token EXACTLY ONCE on initial load
120+
if (!isInitialSetupDone) {
121+
val targetId = intent.getIntExtra(Core.ARG_REPORT_ID, -1)
122+
if (targetId != -1) {
123+
val index = reports.indexOfFirst { it.id == targetId }
124+
if (index != -1) {
125+
title = reports[index].filename
126+
activityReportDetailBinding.pager.setCurrentItem(index, false)
127+
}
128+
}
129+
isInitialSetupDone = true // Lock it so subsequent DB updates don't snap the user back
130+
} else {
131+
// Update the title dynamically based on the current visible item during background updates
132+
val currentIndex = activityReportDetailBinding.pager.currentItem
133+
if (currentIndex in reports.indices) {
134+
title = reports[currentIndex].filename
135+
}
111136
}
112137
}
113-
})
114-
}
115-
116-
override fun onStop() {
117-
super.onStop()
118-
119-
// clear all the subscription
120-
disposable.clear()
138+
}
139+
}
121140
}
122141

123142
override fun finish() {

Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportDetailFragment.kt

Lines changed: 38 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.core.view.MenuProvider
1717
import androidx.documentfile.provider.DocumentFile
1818
import androidx.fragment.app.Fragment
1919
import androidx.lifecycle.Lifecycle
20+
import androidx.lifecycle.lifecycleScope
2021
import androidx.preference.PreferenceManager.getDefaultSharedPreferences
2122

2223
import android.os.Bundle
@@ -25,15 +26,14 @@ import android.content.Context
2526
import android.view.*
2627
import android.widget.Toast
2728

28-
import io.reactivex.android.schedulers.AndroidSchedulers
29-
import io.reactivex.disposables.CompositeDisposable
30-
import io.reactivex.schedulers.Schedulers
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.launch
31+
import kotlinx.coroutines.withContext
3132

3233
import net.mediaarea.mediainfo.databinding.ReportDetailBinding
3334

3435

3536
class ReportDetailFragment : Fragment() {
36-
private val disposable: CompositeDisposable = CompositeDisposable()
3737
private lateinit var activityListener: ReportActivityListener
3838
private var sharedPreferences: SharedPreferences? = null
3939
private lateinit var reportDetailBinding: ReportDetailBinding
@@ -52,32 +52,31 @@ class ReportDetailFragment : Fragment() {
5252
return@let
5353
}
5454

55-
disposable
56-
.add(activityListener.getReportViewModel().getReport(it)
57-
.subscribeOn(Schedulers.io())
58-
.observeOn(AndroidSchedulers.mainThread())
59-
.subscribe { report: Report ->
60-
if (report.report.isEmpty()) {
55+
lifecycleScope.launch {
56+
withContext(Dispatchers.IO) {
57+
val report = activityListener.getReportViewModel().getReport(it)
58+
if (report == null || report.report.isEmpty()) {
59+
onError()
60+
} else {
61+
val currentContext: Context? = context
62+
if (currentContext == null) {
6163
onError()
6264
} else {
63-
val currentContext: Context? = context
64-
if (currentContext == null) {
65+
val directory = DocumentFile.fromTreeUri(currentContext, uri)
66+
67+
if (directory == null) {
6568
onError()
6669
} else {
67-
val directory = DocumentFile.fromTreeUri(currentContext, uri)
68-
69-
if (directory == null) {
70+
if (!directory.canWrite()) {
7071
onError()
7172
} else {
72-
if (!directory.canWrite()) {
73-
onError()
74-
} else {
75-
saveReport(directory, report)
76-
}
73+
saveReport(directory, report)
7774
}
7875
}
7976
}
80-
})
77+
}
78+
}
79+
}
8180
}
8281
}
8382

@@ -120,36 +119,30 @@ class ReportDetailFragment : Fragment() {
120119
}
121120
}
122121

123-
override fun onStop() {
124-
super.onStop()
125-
126-
// clear all the subscription
127-
disposable.clear()
128-
}
129-
130122
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
131123
savedInstanceState: Bundle?): View {
132124

133125
//val rootView = inflater.inflate(R.layout.report_detail, container, false)
134126
// Show the report
135127
id?.let { id ->
136-
disposable.add(activityListener.getReportViewModel().getReport(id)
137-
.subscribeOn(Schedulers.io())
138-
.observeOn(AndroidSchedulers.mainThread())
139-
.doOnSuccess {
140-
val report: String = Core.convertReport(it.report, view)
141-
var content = ""
142-
if (view != "HTML") {
143-
content += "<html><head>" +
144-
"<style>:root { color-scheme: var(--color-scheme, light); } @media (prefers-color-scheme: dark) { :root { --color-scheme: dark; } }</style>" +
145-
"</head><body><pre>"
146-
content += report.replace("\t", " ").htmlEncode()
147-
content += "</pre></body></html>"
148-
} else {
149-
content+=report
150-
}
151-
reportDetailBinding.reportDetail.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
152-
}.subscribe())
128+
lifecycleScope.launch {
129+
val content = withContext(Dispatchers.IO) {
130+
val reportObject = activityListener.getReportViewModel().getReport(id)
131+
val report: String = if (reportObject != null ) Core.convertReport(reportObject.report, view) else "Error fetching report."
132+
var content = ""
133+
if (view != "HTML") {
134+
content += "<html><head>" +
135+
"<style>:root { color-scheme: var(--color-scheme, light); } @media (prefers-color-scheme: dark) { :root { --color-scheme: dark; } }</style>" +
136+
"</head><body><pre>"
137+
content += report.replace("\t", " ").htmlEncode()
138+
content += "</pre></body></html>"
139+
} else {
140+
content+=report
141+
}
142+
content
143+
}
144+
reportDetailBinding.reportDetail.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
145+
}
153146
}
154147

155148
return reportDetailBinding.root

0 commit comments

Comments
 (0)