Skip to content

Commit 62766d2

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

8 files changed

Lines changed: 429 additions & 234 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: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,76 @@
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+
/**
18+
* Adapter responsible for managing the lifecycle and presentation of [ReportDetailFragment]
19+
* instances within a dynamic data collection stream.
20+
*/
21+
class PagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
22+
23+
private var reports: List<Report> = emptyList()
24+
25+
/**
26+
* Required for stable fragment reuse and structural mutation tracking.
27+
*/
28+
override fun getItemId(position: Int): Long {
29+
return reports[position].id.toLong()
30+
}
31+
32+
/**
33+
* Informs ViewPager2 whether a specific fragment identity still exists in the dataset.
34+
*/
35+
override fun containsItem(itemId: Long): Boolean {
36+
return reports.any { it.id.toLong() == itemId }
37+
}
38+
39+
/**
40+
* Calculates the structural differences between the current and incoming datasets
41+
* using an optimized metadata-only comparison strategy.
42+
*/
43+
fun submitList(newReports: List<Report>) {
44+
val oldReports = this.reports
45+
46+
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
47+
override fun getOldListSize(): Int = oldReports.size
48+
override fun getNewListSize(): Int = newReports.size
49+
50+
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
51+
return oldReports[oldItemPosition].id == newReports[newItemPosition].id
52+
}
53+
54+
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
55+
val oldItem = oldReports[oldItemPosition]
56+
val newItem = newReports[newItemPosition]
57+
58+
// Intentionally bypasses binary blob (ByteArray) comparison to avoid main-thread performance degradation.
59+
// Evaluation is strictly restricted to structural presentation properties.
60+
return oldItem.id == newItem.id &&
61+
oldItem.filename == newItem.filename &&
62+
oldItem.version == newItem.version
63+
}
64+
})
65+
66+
this.reports = newReports
67+
diffResult.dispatchUpdatesTo(this)
68+
}
1669

1770
override fun createFragment(position: Int): Fragment {
1871
return ReportDetailFragment().apply {
1972
arguments = Bundle().apply {
20-
putInt(Core.ARG_REPORT_ID, reports.elementAt(position).id)
73+
putInt(Core.ARG_REPORT_ID, reports[position].id)
2174
}
2275
}
2376
}
2477

2578
override fun getItemCount(): Int {
26-
return reports.count()
79+
return reports.size
2780
}
28-
2981
}

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: 58 additions & 29 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 {
@@ -35,12 +36,14 @@ class ReportDetailActivity : AppCompatActivity(), ReportActivityListener {
3536
private inner class PageChangeListener(private val reports: List<Report>) : ViewPager2.OnPageChangeCallback() {
3637
override fun onPageSelected(position: Int) {
3738
super.onPageSelected(position)
38-
title = reports.elementAt(position).filename
39-
intent.putExtra(Core.ARG_REPORT_ID, reports.elementAt(position).id)
4039

40+
// CRITICAL BOUNDS CHECK: Verify position is valid inside the current data allocation range
41+
if (position >= 0 && position < reports.size) {
42+
title = reports.elementAt(position).filename
43+
intent.putExtra(Core.ARG_REPORT_ID, reports.elementAt(position).id)
44+
}
4145
}
4246
}
43-
private val disposable: CompositeDisposable = CompositeDisposable()
4447
private lateinit var reportModel: ReportViewModel
4548

4649
override fun getReportViewModel(): ReportViewModel {
@@ -96,28 +99,54 @@ class ReportDetailActivity : AppCompatActivity(), ReportActivityListener {
9699
val viewModelFactory = Injection.provideViewModelFactory(this)
97100
reportModel = ViewModelProvider(this, viewModelFactory)[ReportViewModel::class.java]
98101

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)
102+
// From Google Gemini 3.5 Flash
103+
// 1. Initialize the adapter ONCE
104+
val pagerAdapter = PagerAdapter(this)
105+
activityReportDetailBinding.pager.adapter = pagerAdapter
106+
// 2. Track your active listener reference locally
107+
var pageChangeListener: ViewPager2.OnPageChangeCallback? = null
108+
// 3. Collect data changes safely inside the single lifecycle coroutine
109+
lifecycleScope.launch {
110+
repeatOnLifecycle(Lifecycle.State.STARTED) {
111+
// We use a flag to track if we have already run the initial intent-navigation jump
112+
var isInitialSetupDone = false
113+
reportModel.getAllReports().collectLatest { reports: List<Report> ->
114+
// A. Update the listener with the fresh data list safely without adding a new callback instance
115+
pageChangeListener?.let { oldListener ->
116+
activityReportDetailBinding.pager.unregisterOnPageChangeCallback(oldListener)
117+
}
118+
// CRITICAL: Handle empty state gracefully before configuring UI listeners
119+
if (reports.isEmpty()) {
120+
title = getString(R.string.app_name)
121+
pagerAdapter.submitList(emptyList())
122+
pageChangeListener = null
123+
return@collectLatest // Short-circuit out of this emission safely
124+
}
125+
pageChangeListener = PageChangeListener(reports)
126+
activityReportDetailBinding.pager.registerOnPageChangeCallback(pageChangeListener!!)
127+
// B. Push the new items cleanly into your existing adapter
128+
pagerAdapter.submitList(reports)
129+
// C. Handle the intent navigation token EXACTLY ONCE on initial load
130+
if (!isInitialSetupDone) {
131+
val targetId = intent.getIntExtra(Core.ARG_REPORT_ID, -1)
132+
if (targetId != -1) {
133+
val index = reports.indexOfFirst { it.id == targetId }
134+
if (index != -1) {
135+
title = reports[index].filename
136+
activityReportDetailBinding.pager.setCurrentItem(index, false)
137+
}
138+
}
139+
isInitialSetupDone = true // Lock it so subsequent DB updates don't snap the user back
140+
} else {
141+
// Update the title dynamically based on the current visible item during background updates
142+
val currentIndex = activityReportDetailBinding.pager.currentItem
143+
if (currentIndex in reports.indices) {
144+
title = reports[currentIndex].filename
145+
}
111146
}
112147
}
113-
})
114-
}
115-
116-
override fun onStop() {
117-
super.onStop()
118-
119-
// clear all the subscription
120-
disposable.clear()
148+
}
149+
}
121150
}
122151

123152
override fun finish() {

0 commit comments

Comments
 (0)