Skip to content

Android GUI: Migrate Rx Java to Kotlin Coroutines#1285

Open
cjee21 wants to merge 7 commits into
MediaArea:masterfrom
cjee21:coroutines
Open

Android GUI: Migrate Rx Java to Kotlin Coroutines#1285
cjee21 wants to merge 7 commits into
MediaArea:masterfrom
cjee21:coroutines

Conversation

@cjee21
Copy link
Copy Markdown
Collaborator

@cjee21 cjee21 commented May 20, 2026

Resolve #1284 while migrating fully to Kotlin Coroutines and reducing the number of dependencies at the same time.

Also fix #1288

@cjee21

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the Android GUI data layer and UI observers from RxJava to Kotlin coroutines/Flow to address issue #1284 (Room EmptyResultSetException) and reduce dependencies.

Changes:

  • Converted Room DAO APIs from RxJava (Single/Flowable) to suspend and Flow.
  • Updated ViewModel/Activities/Fragments to use lifecycleScope coroutines and Flow collection instead of Rx subscriptions.
  • Reduced coroutine/Rx dependencies in the Android app Gradle config.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportViewModel.kt Replaced Rx-based DB operations with suspend calls and Flow exposure; adjusted navigation logic after inserts.
Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportListActivity.kt Replaced Rx subscriptions with coroutine launches and Flow collection for list updates and title loading.
Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportDetailFragment.kt Replaced Rx fetching/export flow with coroutine-based fetching and background conversion.
Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportDetailActivity.kt Replaced Rx list observation with Flow collection to drive pager updates.
Source/GUI/Android/app/src/main/java/net/mediaarea/mediainfo/ReportDao.kt Migrated DAO methods to suspend and Flow.
Source/GUI/Android/app/build.gradle Removed RxJava deps and adjusted coroutine dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 20 to +27
@Query("SELECT MAX(id) FROM reports")
fun getLastId(): Single<Int>
suspend fun getLastId(): Int?

@Query("SELECT * FROM reports WHERE id = :id")
fun getReport(id: Int): Single<Report>
suspend fun getReport(id: Int): Report

@Query("SELECT * FROM reports ORDER BY id")
fun getAllReports(): Flowable<List<Report>>
fun getAllReports(): Flow<List<Report>>
insertReport(Report(0, displayName, report, Core.version))
.subscribeOn(Schedulers.io()).await()
fd.use {
val report: ByteArray = Core.createReport(it.detachFd(), displayName)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core.createReport will pass to MediaInfoLib's OpenFd which will close the fd there. Gemini recommends using fd.use so that any remaining 'shell' after detaching will be cleaned up properly.

fun getReport(id: Int): Single<Report> {
return dataSource.getReport(id)
}
suspend fun getReport(id: Int): Report = dataSource.getReport(id)
Comment on lines +663 to +690
override fun onStart() {
super.onStart()

disposable.add(reportModel.getAllReports()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
reports = it
setupRecyclerView(activityReportListBinding.reportListLayout.reportList)

val rootLayout: FrameLayout = findViewById(R.id.frame_layout)
if (reports.isEmpty()) {
var found = false
for (i: Int in rootLayout.childCount downTo 1) {
if (rootLayout.getChildAt(i - 1).id == R.id.hello_layout)
found = true
}
lifecycleScope.launch {
reportModel.getAllReports().collectLatest {
reports = it
setupRecyclerView(activityReportListBinding.reportListLayout.reportList)

val rootLayout: FrameLayout = findViewById(R.id.frame_layout)
if (reports.isEmpty()) {
var found = false
for (i: Int in rootLayout.childCount downTo 1) {
if (rootLayout.getChildAt(i - 1).id == R.id.hello_layout)
found = true
}

if (!found)
View.inflate(this, R.layout.hello_layout, rootLayout)
if (!found)
View.inflate(this@ReportListActivity, R.layout.hello_layout, rootLayout)

} else {
for (i: Int in rootLayout.childCount downTo 1) {
if (rootLayout.getChildAt(i - 1).id == R.id.hello_layout)
rootLayout.removeViewAt(i - 1)
}
rootLayout.removeView(helloLayoutBinding.root)
} else {
for (i: Int in rootLayout.childCount downTo 1) {
if (rootLayout.getChildAt(i - 1).id == R.id.hello_layout)
rootLayout.removeViewAt(i - 1)
}
})
rootLayout.removeView(helloLayoutBinding.root)
}
}
}
Comment on lines +416 to +418
lifecycleScope.launch {
title = reportModel.getReport(id).filename
}
Comment on lines +735 to +745
reportModel.deleteAllReports()
intent.putExtra(Core.ARG_REPORT_ID, -1)
if (twoPane) {
val fragment = supportFragmentManager.findFragmentById(R.id.report_detail_container)
if (fragment != null) {
supportFragmentManager
.beginTransaction()
.detach(fragment)
.commit()

title = getString(R.string.app_name)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an issue if cleanup of UI is run first while report is deleting? We're deleting all anyway.

.subscribe { report: Report ->
if (report.report.isEmpty()) {
lifecycleScope.launch {
val report = activityListener.getReportViewModel().getReport(it)
saveReport(directory, report)
}
}
saveReport(directory, report)
Comment on lines +97 to +104
lifecycleScope.launch {
reportModel.getAllReports().collectLatest { reports: List<Report> ->
activityReportDetailBinding.pager.registerOnPageChangeCallback(
PageChangeListener(
reports
)
)
activityReportDetailBinding.pager.adapter = PagerAdapter(this@ReportDetailActivity, reports)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bug is already there before migrating to coroutines. Will see what can be done.

Comment on lines 215 to 229
// Android Room
// can only upgrade when minSdkVersion >= 23
implementation 'androidx.room:room-runtime:2.7.2'
implementation 'androidx.room:room-rxjava2:2.7.2'
ksp 'androidx.room:room-compiler:2.7.2'
//RxJava
implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
// Android Lifecycle
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
ksp "androidx.lifecycle:lifecycle-common-java8:2.10.0"
// Google Billing
// can only upgrade when minSdkVersion >= 23
implementation "com.android.billingclient:billing:8.0.0"
implementation "com.android.billingclient:billing-ktx:8.0.0"
// KotlinX Coroutines
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.10.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0"
// Locales manager
@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 20, 2026

Let's see if there are anymore issues. I do not know about pager adapter stuff so that is almost 100% Google Gemini AI written, not sure if that is fine.

@JeromeMartinez
Copy link
Copy Markdown
Member

snapshot (but before the last update).

@JeromeMartinez
Copy link
Copy Markdown
Member

@copilot please review the latest changes and author's answers.

@JeromeMartinez
Copy link
Copy Markdown
Member

Snapshot

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

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

  • The fallback text "Error fetching report." is hard-coded and not localized. If this is meant to be shown to users (it’s rendered into the WebView), it should come from a string resource (and ideally be consistent with existing error handling like onError()).
                val content = withContext(Dispatchers.IO) {
                    val reportObject = activityListener.getReportViewModel().getReport(id)
                    val report: String = if (reportObject != null ) Core.convertReport(reportObject.report, view) else "Error fetching report."
                    var content = ""
                    if (view != "HTML") {

Comment on lines +115 to +116
pageChangeListener = PageChangeListener(reports)
activityReportDetailBinding.pager.registerOnPageChangeCallback(pageChangeListener)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it built fine. "should not compile." is wrong. When we assign pageChangeListener = PageChangeListener(reports) Kotlin is smart enough to smart cast and know it will be non-null at the next line. Suggested change just introduces another variable.

Comment on lines +55 to +78
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val report = activityListener.getReportViewModel().getReport(it)
if (report == null || report.report.isEmpty()) {
onError()
} else {
val currentContext: Context? = context
if (currentContext == null) {
onError()
} else {
val currentContext: Context? = context
if (currentContext == null) {
val directory = DocumentFile.fromTreeUri(currentContext, uri)

if (directory == null) {
onError()
} else {
val directory = DocumentFile.fromTreeUri(currentContext, uri)

if (directory == null) {
if (!directory.canWrite()) {
onError()
} else {
if (!directory.canWrite()) {
onError()
} else {
saveReport(directory, report)
}
saveReport(directory, report)
}
}
}
})
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then the initial suggestion is wrong too as inside saveReport, there are onError() too. But it can be solved.

Comment on lines +44 to +45
// Since Report is a data class, this structurally compares all properties
return oldReports[oldItemPosition] == newReports[newItemPosition]
Comment on lines +118 to +134
/* unused
suspend fun getLastId(): Int? = dataSource.getLastId()
*/

fun getReport(id: Int): Single<Report> {
return dataSource.getReport(id)
}
suspend fun getReport(id: Int): Report? = dataSource.getReport(id)

fun getAllReports(): Flowable<List<Report>> {
return dataSource.getAllReports()
}
fun getAllReports(): Flow<List<Report>> = dataSource.getAllReports()

fun insertReport(report: Report): Completable {
return Completable.fromAction {
dataSource.insertReport(report)
}
}
suspend fun insertReport(report: Report): Long = dataSource.insertReport(report)

/* unused
fun updateReport(report: Report): Completable {
return Completable.fromAction {
fun updateReport(report: Report) {
viewModelScope.launch(Dispatchers.IO) {
dataSource.updateReport(report)
}
}
*/
*/
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just follow the original style of doing this.

@JeromeMartinez
Copy link
Copy Markdown
Member

No idea about the technic part, but testing as a user, I can say that it is very smooth, also on hundreds of videos files, and switching between files is nice.

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

No idea about the technic part, but testing as a user, I can say that it is very smooth, also on hundreds of videos files, and switching between files is nice.

That's good then. I think I have addressed all issues found by Copilot. The list should be even smoother with nice fluid animations when adding and deleting reports after the other PR (which now likely needs some work to rebase after this PR).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Comment on lines +102 to +105
fd.use {
val report: ByteArray = Core.createReport(it.detachFd(), displayName)
lastInsertedId = insertReport(Report(0, displayName, report, Core.version)).toInt()
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini: In practice, no, it will likely never exceed Int.MAX_VALUE in your specific app, but it depends entirely on how your database is used.Int.MAX_VALUE is 2,147,483,647. To break that limit, a user would have to import or generate over 2.1 billion reports on a single device. Even if an automated script inserted 10,000 reports every single day, it would take over 580 years to exceed that limit.However, there is one technical nuance with SQLite that you should be aware of:The SQLite rowid LifecycleIn SQLite, if you do not specify a value for a primary key, it uses an internal rowid that can automatically scale up to a 64-bit Long ($9 \times 10^{18}$).Because your Report entity explicitly uses an Int for its primary key, Room enforces a 32-bit ceiling on the database column itself. If the database did somehow reach 2,147,483,647, the next insertion would cause an auto-increment overflow error (SQLiteException: database or disk is full) long before insertReport could return a value that would wrap during a .toInt() conversion.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 billions files is not expected in reality, right :). But who knows, in an hypothetical future...
I am fine to keep that for the moment, until someone wants to fix that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because your Report entity explicitly uses an Int for its primary key, Room enforces a 32-bit ceiling on the database column itself. If the database did somehow reach 2,147,483,647, the next insertion would cause an auto-increment overflow error (SQLiteException: database or disk is full) long before insertReport could return a value that would wrap during a .toInt() conversion.

So we cannot do anything anyway. Unless changing existing ID from Int to Long which I do not know if will break existing databases as I am no database expert.

Comment on lines +129 to +142
val report: String = if (reportObject != null ) Core.convertReport(reportObject.report, view) else ""
var content = ""
if (view != "HTML") {
content += "<html><head>" +
"<style>:root { color-scheme: var(--color-scheme, light); } @media (prefers-color-scheme: dark) { :root { --color-scheme: dark; } }</style>" +
"</head><body><pre>"
content += report.replace("\t", " ").htmlEncode()
content += "</pre></body></html>"
} else {
content+=report
}
content
}
reportDetailBinding.reportDetail.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll let this be. Unlikely to ever happen. Previously getReport can't even return null and would have exception.

Comment on lines 222 to 236
@@ -244,9 +235,11 @@ class ReportDetailFragment : Fragment() {
if (ostream == null) {
onError()
Comment on lines +233 to +242
ostream.write(reportText.toByteArray())
ostream.flush()
ostream.close()
}
Comment on lines +115 to +116
pageChangeListener = PageChangeListener(reports)
activityReportDetailBinding.pager.registerOnPageChangeCallback(pageChangeListener)
Comment on lines +43 to +48
fun submitList(newReports: List<Report>) {
val oldReports = this.reports

val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldReports.size
override fun getNewListSize(): Int = newReports.size
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini confirmed what I thought, most of these protections and handling are actually not really needed due to the app not having any list manipulation while viewing the viewpager.


Given your specific application architecture, no, it is not worth adding the extra complexity of an asynchronous differ here.

The code review comment is a standard, textbook recommendation for RecyclerView lists, but it overlooks two critical real-world details about how your specific screen works:

  1. Your Workflow Minimizes Changes

Operations that mutate this list (like adding or deleting reports) happen on the Main Activity, not while the user is actively swiping through pages on this detail screen. Because updates on this screen are incredibly rare, the chances of triggering a background-sync diff calculation mid-swipe are close to zero.

  1. ViewPager Lists Are Naturally Small

AsyncListDiffer is built for infinite scrolling feeds (like a social media timeline or an inventory catalog) with thousands of items. A ViewPager2 detail view typically holds a manageable number of items (e.g., dozens or low hundreds).

Running DiffUtil on a couple hundred items using only lightweight metadata fields takes less than 1 millisecond, which easily fits into the 16ms frame budget of the Main Thread without causing any visual stutter (jank).

@JeromeMartinez
Copy link
Copy Markdown
Member

JeromeMartinez commented May 21, 2026

snapshot.

@JeromeMartinez JeromeMartinez requested a review from g-maxime May 21, 2026 09:07
@cjee21 cjee21 force-pushed the coroutines branch 2 times, most recently from 62766d2 to 4cbc22a Compare May 21, 2026 09:13
@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Most of the edge cases we are handling likely will never happen since we cannot actually get to the ViewPager activity when there are no reports.

Anyway, asked Gemini to review again and made some more suggested improvements that look fine. Gemini also found some other issues that Copilot did not. Also fixed the export file part by switching between two contexts depending on whether doing file operations or showing error in UI as well as better handling closing file stream as recommended by Gemini.

@JeromeMartinez
Copy link
Copy Markdown
Member

@cjee21 thank you, I'll ask a last time to Copilot in case there is something useful, then @g-maxime will test on several old Android in order to be sure all is fine, then... We'll see in production if we have less crashes!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.


// Show the report
id?.let { id ->
lifecycleScope.launch {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah this is what Copilot missed initially but Gemini found and I just forgot to fix this one after fixing the one above. Fixed now.

Comment on lines +631 to +632
setupRecyclerView(activityReportListBinding.reportListLayout.reportList)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is old bug. Why did it only see it now? Anyway this is already fixed in the other PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway this is already fixed in the other PR.

The other PR will be stalled until the abandon of old Android, is it possible to migrate everything not related to SDK updates to this PR?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably difficult. Since I only started noticing and fixing these after changing the structure of the list to the card look. Also fixing this required redesigning the entire recyclerview handling IIRC.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to port it. Maybe possible. See if it builds and works....

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to port it. Maybe possible. See if it builds and works....

🧡

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick test seems to work. Now adding and removing items have smooth animations and list should maintain position. Will also auto scroll to current report when opening new report in two pane. Also includes some logic that is not used at the moment but will be used when implementing the other PR such as highlighting selected report and rounding first and last cards.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think just port this one here. The other changes are not affecting functionality or fixing bugs and just design change, minor code fixes and adding new libcurl feature that can wait till min API increase.

@cjee21 cjee21 force-pushed the coroutines branch 2 times, most recently from c01df2e to 4bbd058 Compare May 21, 2026 10:02
@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Still so many issues with that file!

Details

Here is a comprehensive code review of your original ReportDetailFragment implementation. This review consolidates the architectural, lifecycle, thread-safety, and code-quality issues present in the code, along with targeted recommendations on how to fix them.


1. Lifecycle & View Integrity Issues

Multiple Layout Inflations (Wasted Resources & Potential Bugs)

In onAttach, you call ReportDetailBinding.inflate(layoutInflater). Then, in onCreateView, you overwrite reportDetailBinding with a brand-new inflation:

override fun onAttach(context: Context) {
    ...
    reportDetailBinding = ReportDetailBinding.inflate(layoutInflater) // First inflation (Discarded)
}

override fun onCreateView(...): View {
    reportDetailBinding = ReportDetailBinding.inflate(inflater, container, false) // Second inflation
    return reportDetailBinding.root
}
  • The Problem: The view hierarchy inflated in onAttach is never used, never attached to a window, and immediately discarded. This wastes CPU and memory cycles.
  • The Fix: Remove the inflation logic from onAttach completely. Layout layout inflation belongs strictly inside onCreateView.

Memory Leaks via Strong ViewBinding Reference

You are holding a hard reference to private lateinit var reportDetailBinding: ReportDetailBinding.

  • The Problem: Fragment instances often outlive their views (for example, when a fragment is placed on the backstack). Because you hold a strong reference to the binding object, the entire UI view hierarchy cannot be garbage collected when the view is destroyed, leading to a memory leak.
  • The Fix: Use a nullable backing property that clears itself in onDestroyView():
private var _binding: ReportDetailBinding? = null
private val binding get() = _binding!!

override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
}

2. Asynchronous Programming & Thread Safety

Calling UI Methods from a Background Thread

Inside saveReport, you switch to a background thread using withContext(Dispatchers.IO). Inside the try-catch block, you call onError() on the background thread:

private suspend fun saveReport(directory: DocumentFile, report: Report) {
    withContext(Dispatchers.IO) {
        ...
        try {
            val document = directory.createFile(...) ?: run {
                withContext(Dispatchers.Main) { onError() } // Correctly switches threads
                return@withContext
            }
            ...
        } catch (_: Exception) {
            onError() // DANGER: This runs on Dispatchers.IO!
        }
    }
}
  • The Problem: If an exception is thrown during file writing, onError() is called directly on Dispatchers.IO. Inside onError(), you display a Toast. In Android, touching UI elements or displaying Toasts from a non-UI thread causes a CalledFromWrongThreadException crash.
  • The Fix: Move the error handling out of the background worker entirely by making saveReport return a Boolean success status.

Unsafe Lifecycle Execution for File Writing

In your file-saving flow, you trigger saveReport from inside lifecycleScope.launch.

  • The Nuance: Using lifecycleScope here is actually appropriate because file exporting shouldn't terminate mid-write just because a user shifts focus to another app. However, because it keeps running in the background, executing UI functions like onError() blindly upon completion is dangerous if the user has navigated away and destroyed the fragment views.
  • The Fix: Before displaying UI feedback (like the Toast in onError), check if the UI is still alive by verifying viewLifecycleOwnerLiveData.value != null.

3. Architecture & Clean Code

Fragile Multi-Fragment Coupling

Inside the onMenuItemSelected handler, you loop through all fragments in the parentFragmentManager and force-cast them to recreate them:

parentFragmentManager.fragments.forEach {
    val fragment = it as? ReportDetailFragment
    if (fragment != null) {
        ...
        parentFragmentManager.beginTransaction().detach(it).commit()
        parentFragmentManager.beginTransaction().attach(it).commit()
    }
}
  • The Problem: One fragment is directly controlling, manipulating, and forcing lifecycle cycles on adjacent fragments. If you add different types of fragments, use nested fragments, or implement a view pager layout, this code will become fragile, unpredictable, and cause layout bugs.
  • The Fix: Move the active view state into a Shared ViewModel. Fragments should simply observe a state flow. When the user changes the layout mode, update the state flow, and any active detail fragments will cleanly re-render themselves without tearing down and rebuilding fragments manually.

Code Readability and Scope Shadows

  • The Arrow Anti-Pattern: Inside your registerForActivityResult callback, you have deeply nested if/else checks (id.let -> report == null -> currentContext == null -> directory == null -> !directory.canWrite()). This creates an "arrow" shape that reduces readability. Use guard clauses with clean, early returns (return@registerForActivityResult) to flatten out the script.
  • Variable Shadowing: In onViewCreated(view: View, ...), the incoming UI object is named view. In the same block, you point to your global string variable using this@ReportDetailFragment.view. This duplication is highly error-prone. Rename your class string to something explicit like currentViewMode.

Final Recommended Blueprint

Applying all the changes discussed above results in a lean, safe, and modern implementation:

class ReportDetailFragment : Fragment() {
    private lateinit var activityListener: ReportActivityListener
    private var sharedPreferences: SharedPreferences? = null
    
    // Leak-safe view binding setup
    private var _binding: ReportDetailBinding? = null
    private val binding get() = _binding!!
    
    // Explicitly renamed to avoid variable shadowing with Android UI View
    private var currentViewMode: String = "HTML"
    private var reportId: Int? = null

    private val saveFile = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
        val id = reportId
        if (uri == null || id == null) {
            onError()
            return@registerForActivityResult
        }

        // Using fragment lifecycleScope so the data operation finishes even if app goes to background
        viewLifecycleOwner.lifecycleScope.launch {
            val report = activityListener.getReportViewModel().getReport(id)
            val currentContext = context
            
            // Clean guard clauses to flatten code layout
            if (report == null || report.report.isEmpty() || currentContext == null) {
                onError()
                return@launch
            }

            val directory = DocumentFile.fromTreeUri(currentContext, uri)
            if (directory == null || !directory.canWrite()) {
                onError()
                return@launch
            }

            // Save report returns a completion flag
            val isSavedSuccessfully = saveReport(directory, report)

            // Ensure UI updates run securely on the Main thread and check lifecycle availability
            withContext(Dispatchers.Main) {
                if (!isSavedSuccessfully && viewLifecycleOwnerLiveData.value != null) {
                    onError()
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            if (it.containsKey(Core.ARG_REPORT_ID)) {
                val newId = it.getInt(Core.ARG_REPORT_ID)
                if (newId != -1) reportId = newId
            }
        }
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        try {
            activityListener = activity as ReportActivityListener
        } catch (_: Throwable) {
            throw ClassCastException("$activity must implement ReportActivityListener")
        }

        sharedPreferences = getDefaultSharedPreferences(context)
        val oldSharedPreferences = activity?.getSharedPreferences(getString(R.string.preferences_key), Context.MODE_PRIVATE)
        val key = getString(R.string.preferences_view_key)

        if (sharedPreferences?.contains(key) == false && oldSharedPreferences?.contains(key) == true) {
            sharedPreferences?.edit {
                putString(key, oldSharedPreferences.getString(key, "HTML"))
            }
        }

        currentViewMode = sharedPreferences?.getString(key, "HTML") ?: "HTML"
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        // Layout inflation happens only once here
        _binding = ReportDetailBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        displayReport()
        setupMenu()
    }

    private fun displayReport() {
        val id = reportId ?: return
        viewLifecycleOwner.lifecycleScope.launch {
            val content = withContext(Dispatchers.IO) {
                val reportObject = activityListener.getReportViewModel().getReport(id)
                val report = if (reportObject != null) Core.convertReport(reportObject.report, currentViewMode) else ""
                
                if (currentViewMode != "HTML") {
                    buildString {
                        append("<html><head><style>:root { color-scheme: var(--color-scheme, light); } @media (prefers-color-scheme: dark) { :root { --color-scheme: dark; } }</style></head><body><pre>")
                        append(report.replace("\t", "    ").htmlEncode())
                        append("</pre></body></html>")
                    }
                } else {
                    report
                }
            }
            binding.reportDetail.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
        }
    }

    private fun setupMenu() {
        val menuHost: MenuHost = requireActivity()
        menuHost.addMenuProvider(object : MenuProvider {
            override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
                menuInflater.inflate(R.menu.menu_detail, menu)
                val viewMenu: SubMenu = menu.findItem(R.id.action_change_view).subMenu ?: return
                
                Core.views.forEachIndexed { index, current ->
                    val desc = if (current.desc == "Text") resources.getString(R.string.text_output_desc) else current.desc
                    viewMenu.add(R.id.menu_views_group, index, Menu.NONE, desc).apply {
                        isCheckable = true
                        isChecked = (current.name == currentViewMode)
                    }
                }
                viewMenu.setGroupCheckable(R.id.menu_views_group, true, true)
            }

            override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
                return when (menuItem.itemId) {
                    R.id.action_export_report -> {
                        saveFile.launch(null)
                        true
                    }
                    else -> if (menuItem.groupId == R.id.menu_views_group) {
                        val selectedView = Core.views[menuItem.itemId].name
                        if (currentViewMode != selectedView) {
                            currentViewMode = selectedView
                            sharedPreferences?.edit { putString(getString(R.string.preferences_view_key), currentViewMode) }
                            
                            // Refresh presentation directly instead of fragment churning
                            displayReport()
                        }
                        true
                    } else {
                        false
                    }
                }
            }
        }, viewLifecycleOwner, Lifecycle.State.RESUMED)
    }

    // Completely separated from UI logic. Focused entirely on File I/O operations.
    private suspend fun saveReport(directory: DocumentFile, report: Report): Boolean = withContext(Dispatchers.IO) {
        val reportText = Core.convertReport(report.report, currentViewMode, true)
        val filename = String.format(Locale.US, "%s.%s", report.filename, currentViewMode)
        val mime = Core.views.find { it.name == currentViewMode }?.mime ?: "text/plain"

        return@withContext try {
            val document = directory.createFile(mime, filename) ?: return@withContext false
            context?.contentResolver?.openOutputStream(document.uri)?.use { ostream ->
                ostream.write(reportText.toByteArray())
                ostream.flush()
            }
            true
        } catch (_: Exception) {
            false
        }
    }

    private fun onError() {
        activity?.applicationContext?.let {
            Toast.makeText(it, R.string.error_write_text, Toast.LENGTH_LONG).show()
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null // Releasing view binding structure to prevent memory leaks
    }
}

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Should be fine if this works properly in actual test. Already went through few rounds with Gemini and it is different every time. The last review seems no major issues. Never ending if keep requesting review from AI. Even it's own suggestions will be criticized by itself the next round.

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Switching views doesn't work now....

@JeromeMartinez
Copy link
Copy Markdown
Member

Never ending if keep requesting review from AI. Even it's own suggestions will be criticized by itself the next round.

No worry, I use it only for hint, chasing potential crashes.
But I need no regression...

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Looks like there may be some real issues with the lifecycle stuff or memory leaks and may crash in rare case if AI is correct, it seems to make sense. But the refactoring is causing many regressions. So maybe revert the last commit and let it be if no issues in actual testing. Wasted time on refactoring but at least know it is not so easy and changing fragment code can also break deleting reports for some reason that I do not know.

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Even after revert still have regression. I do not know which part started causing troubles now.

@JeromeMartinez
Copy link
Copy Markdown
Member

if you didn't keep history, I have some source snapshots at https://mediaarea.net/download/snapshots/branches/source/mediainfo/

@JeromeMartinez
Copy link
Copy Markdown
Member

So maybe revert the last commit and let it be if no issues in actual testing.

I am fine with that if you are confident that the version before the last commit is better than without the PR. I let you decide what you think to be the best version.

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Ok, not wasted. the refactoring seems to work now after some more changes.

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

I think just go with this if no one finds any more issues.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment on lines +112 to +113
*
* From Google Gemini 3.5 Flash
Comment on lines +16 to +19
* Adapter responsible for managing the lifecycle and presentation of [ReportDetailFragment]
* instances within a dynamic data collection stream.
*
* Generated with Google Gemini 3.5 Flash
@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

Looks like it has nothing more to say other than the code comments :)

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 21, 2026

if you didn't keep history, I have some source snapshots at https://mediaarea.net/download/snapshots/branches/source/mediainfo/

FYI can easily revert to any commit using git reset with hash. If do not know hash can refer to git reflog for recent hashes. If local copy is gone (garbage collected) can usually fetch any pushed commit from GitHub if know the full hash which can be obtained from PR history. GitHub seems to keep commits forever (so reminder to be careful not to accidentally push secrets).

@JeromeMartinez
Copy link
Copy Markdown
Member

JeromeMartinez commented May 21, 2026

Snapshot

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 22, 2026

Found and fixed bug after the refactoring where view selection menu does not update the checked item when changed.

@JeromeMartinez
Copy link
Copy Markdown
Member

Snapshot.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment on lines +184 to +189
private fun showReport() {
val id = reportId ?: return
val mode = currentViewMode
// Show the report
viewLifecycleOwner.lifecycleScope.launch {
val reportObject = activityListener.getReportViewModel().getReport(id)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think low risk? Loading is usually way faster than a user can open the menu and select another mode. In rare case that it is slow and older job overwrites, user can just tap the desired mode again in the menu.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think low risk?

I agree.

@JeromeMartinez
Copy link
Copy Markdown
Member

@cjee21 no pressure about Copilot feedback, it is just if it hints something you consider useful, else code can stay as is.

@cjee21
Copy link
Copy Markdown
Collaborator Author

cjee21 commented May 22, 2026

Static analysis doesn't detect any issues beyond code style as well. So I think should be fine if actual testing finds no issues. I added another two commits from the other PR to reduce the commits of the other PR. Just minor changes that doesn't affect functionality, no review needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android UI net.mediaarea.mediainfo.ReportListActivity.onRequestPermissionsResult exception Android UI exception

3 participants