Skip to content

Commit 1389fbe

Browse files
committed
feat: media check viewmodel
feat: add media check fragment file - feat: adapter for media check result - refactor: confirm media check dialog
1 parent 25d8511 commit 1389fbe

File tree

8 files changed

+457
-3
lines changed

8 files changed

+457
-3
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.mediacheck
19+
20+
import android.content.Context
21+
import android.view.LayoutInflater
22+
import android.view.View
23+
import android.view.ViewGroup
24+
import android.widget.TextView
25+
import androidx.recyclerview.widget.DiffUtil
26+
import androidx.recyclerview.widget.ListAdapter
27+
import androidx.recyclerview.widget.RecyclerView
28+
import com.ichi2.anki.R
29+
30+
class MediaCheckAdapter(
31+
context: Context,
32+
) : ListAdapter<String, MediaCheckAdapter.ViewHolder>(DiffCallback()) {
33+
private val inflater = LayoutInflater.from(context)
34+
35+
class ViewHolder(
36+
itemView: View,
37+
) : RecyclerView.ViewHolder(itemView) {
38+
val textView: TextView = itemView.findViewById(R.id.file_name_textview)
39+
}
40+
41+
override fun onCreateViewHolder(
42+
parent: ViewGroup,
43+
viewType: Int,
44+
) = ViewHolder(inflater.inflate(R.layout.item_media_check, parent, false))
45+
46+
override fun onBindViewHolder(
47+
holder: ViewHolder,
48+
position: Int,
49+
) {
50+
holder.textView.text = getItem(position)
51+
}
52+
53+
class DiffCallback : DiffUtil.ItemCallback<String>() {
54+
override fun areItemsTheSame(
55+
oldItem: String,
56+
newItem: String,
57+
) = oldItem == newItem
58+
59+
override fun areContentsTheSame(
60+
oldItem: String,
61+
newItem: String,
62+
) = true
63+
}
64+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.mediacheck
19+
20+
import android.content.Context
21+
import android.content.Intent
22+
import android.os.Bundle
23+
import android.view.View
24+
import android.widget.TextView
25+
import androidx.appcompat.app.AlertDialog
26+
import androidx.fragment.app.Fragment
27+
import androidx.fragment.app.viewModels
28+
import androidx.lifecycle.lifecycleScope
29+
import androidx.recyclerview.widget.RecyclerView
30+
import com.google.android.material.appbar.MaterialToolbar
31+
import com.google.android.material.button.MaterialButton
32+
import com.ichi2.anki.CollectionManager.TR
33+
import com.ichi2.anki.R
34+
import com.ichi2.anki.SingleFragmentActivity
35+
import com.ichi2.anki.launchCatchingTask
36+
import com.ichi2.anki.ui.internationalization.toSentenceCase
37+
import com.ichi2.anki.withProgress
38+
import com.ichi2.libanki.MediaCheckResult
39+
import com.ichi2.utils.cancelable
40+
import com.ichi2.utils.message
41+
import com.ichi2.utils.negativeButton
42+
import com.ichi2.utils.positiveButton
43+
import com.ichi2.utils.show
44+
import com.ichi2.utils.title
45+
import kotlinx.coroutines.flow.collectLatest
46+
import kotlinx.coroutines.launch
47+
48+
/**
49+
* MediaCheckFragment for displaying a list of media files that are either unused or missing.
50+
* It allows users to tag missing media files or delete unused ones.
51+
**/
52+
class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
53+
private val viewModel: MediaCheckViewModel by viewModels()
54+
private lateinit var adapter: MediaCheckAdapter
55+
56+
private lateinit var deleteMediaButton: MaterialButton
57+
private lateinit var tagMissingButton: MaterialButton
58+
59+
override fun onViewCreated(
60+
view: View,
61+
savedInstanceState: Bundle?,
62+
) {
63+
super.onViewCreated(view, savedInstanceState)
64+
65+
view.findViewById<MaterialToolbar>(R.id.toolbar).apply {
66+
setTitle(TR.mediaCheckCheckMediaAction().toSentenceCase(requireContext(), R.string.check_media))
67+
setNavigationOnClickListener {
68+
requireActivity().onBackPressedDispatcher.onBackPressed()
69+
}
70+
}
71+
72+
deleteMediaButton = view.findViewById(R.id.delete_used_media_button)
73+
tagMissingButton = view.findViewById(R.id.tag_missing_media_button)
74+
75+
val recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView)
76+
77+
adapter = MediaCheckAdapter(requireContext())
78+
recyclerView.adapter = adapter
79+
80+
launchCatchingTask {
81+
withProgress(R.string.check_media_message) {
82+
viewModel.checkMedia().join()
83+
}
84+
}
85+
86+
lifecycleScope.launch {
87+
viewModel.mediaCheckResult.collectLatest { result ->
88+
view.findViewById<TextView>(R.id.unused_media_count)?.apply {
89+
text = (TR.mediaCheckUnusedCount(result?.unusedFileNames?.size ?: 0))
90+
}
91+
92+
view.findViewById<TextView>(R.id.missing_media_count)?.apply {
93+
text = (TR.mediaCheckMissingCount(result?.missingMediaNotes?.size ?: 0))
94+
}
95+
96+
result?.let { files ->
97+
handleMediaResult(files)
98+
}
99+
}
100+
}
101+
102+
setupButtonListeners()
103+
}
104+
105+
/**
106+
* Processes media check results and updates the UI.
107+
*
108+
* @param mediaCheckResult The result containing missing and unused media file names.
109+
*/
110+
private fun handleMediaResult(mediaCheckResult: MediaCheckResult) {
111+
val fileList =
112+
buildList {
113+
if (mediaCheckResult.missingFileNames.isNotEmpty()) {
114+
tagMissingButton.visibility = View.VISIBLE
115+
add(TR.mediaCheckMissingHeader())
116+
addAll(mediaCheckResult.missingFileNames.map(TR::mediaCheckMissingFile))
117+
}
118+
if (mediaCheckResult.unusedFileNames.isNotEmpty()) {
119+
deleteMediaButton.visibility = View.VISIBLE
120+
add("\n")
121+
add(TR.mediaCheckUnusedHeader())
122+
addAll(mediaCheckResult.unusedFileNames.map(TR::mediaCheckUnusedFile))
123+
}
124+
}
125+
126+
adapter.submitList(fileList)
127+
}
128+
129+
private fun setupButtonListeners() {
130+
tagMissingButton.apply {
131+
// mediaCheckAddTag => "Tag Missing"
132+
text = TR.mediaCheckAddTag().toSentenceCase(requireContext(), R.string.tag_missing)
133+
134+
setOnClickListener {
135+
launchCatchingTask {
136+
withProgress(getString(R.string.check_media_adding_missing_tag)) {
137+
viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).join()
138+
showResultDialog(
139+
R.string.check_media_tags_added,
140+
TR.browsingNotesUpdated(viewModel.taggedFiles),
141+
)
142+
}
143+
}
144+
}
145+
}
146+
147+
deleteMediaButton.apply {
148+
text =
149+
TR.mediaCheckDeleteUnused().toSentenceCase(
150+
requireContext(),
151+
R.string.check_media_delete_unused,
152+
)
153+
154+
setOnClickListener {
155+
deleteConfirmationDialog()
156+
}
157+
}
158+
}
159+
160+
private fun deleteConfirmationDialog() {
161+
AlertDialog.Builder(requireContext()).show {
162+
message(text = TR.mediaCheckDeleteUnusedConfirm())
163+
positiveButton(R.string.dialog_ok) { handleDeleteConfirmation() }
164+
negativeButton(R.string.dialog_cancel)
165+
}
166+
}
167+
168+
private fun handleDeleteConfirmation() {
169+
launchCatchingTask {
170+
withProgress(resources.getString(R.string.delete_media_message)) {
171+
viewModel.deleteUnusedMedia().join()
172+
showDeletionResult()
173+
}
174+
}
175+
}
176+
177+
private fun showDeletionResult() {
178+
showResultDialog(
179+
R.string.delete_media_result_title,
180+
resources.getQuantityString(
181+
R.plurals.delete_media_result_message,
182+
viewModel.deletedFiles,
183+
viewModel.deletedFiles,
184+
),
185+
)
186+
}
187+
188+
private fun showResultDialog(
189+
titleRes: Int,
190+
message: String,
191+
) {
192+
AlertDialog.Builder(requireContext()).show {
193+
title(titleRes)
194+
message(text = message)
195+
positiveButton(R.string.dialog_ok) {
196+
requireActivity().finish()
197+
}
198+
cancelable(false)
199+
}
200+
}
201+
202+
companion object {
203+
fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, MediaCheckFragment::class)
204+
}
205+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.mediacheck
19+
20+
import androidx.lifecycle.ViewModel
21+
import androidx.lifecycle.viewModelScope
22+
import com.ichi2.anki.CollectionManager.withCol
23+
import com.ichi2.annotations.NeedsTest
24+
import com.ichi2.async.deleteMedia
25+
import com.ichi2.libanki.MediaCheckResult
26+
import com.ichi2.libanki.undoableOp
27+
import kotlinx.coroutines.Job
28+
import kotlinx.coroutines.flow.MutableStateFlow
29+
import kotlinx.coroutines.flow.StateFlow
30+
import kotlinx.coroutines.launch
31+
32+
@NeedsTest("Test the media check process i.e. the buttons and views")
33+
class MediaCheckViewModel : ViewModel() {
34+
private val _mediaCheckResult = MutableStateFlow<MediaCheckResult?>(null)
35+
val mediaCheckResult: StateFlow<MediaCheckResult?> = _mediaCheckResult
36+
37+
private val deletedFilesCount: MutableStateFlow<Int> = MutableStateFlow(0)
38+
private val taggedFilesCount: MutableStateFlow<Int> = MutableStateFlow(0)
39+
40+
val deletedFiles: Int
41+
get() = deletedFilesCount.value
42+
43+
val taggedFiles: Int
44+
get() = taggedFilesCount.value
45+
46+
// TODO: Move progress notifications here
47+
fun tagMissing(tag: String): Job =
48+
viewModelScope.launch {
49+
val taggedNotes =
50+
undoableOp {
51+
tags.bulkAdd(_mediaCheckResult.value?.missingMediaNotes ?: listOf(), tag)
52+
}
53+
taggedFilesCount.value = taggedNotes.count
54+
}
55+
56+
fun checkMedia(): Job =
57+
viewModelScope.launch {
58+
val result = withCol { media.check() }
59+
_mediaCheckResult.value = result
60+
}
61+
62+
// TODO: investigate: the underlying implementation exposes progress, which we do not yet handle.
63+
fun deleteUnusedMedia(): Job =
64+
viewModelScope.launch {
65+
val deletedMedia = withCol { deleteMedia(this@withCol, _mediaCheckResult.value?.unusedFileNames ?: listOf()) }
66+
deletedFilesCount.value = deletedMedia
67+
}
68+
}

AnkiDroid/src/main/java/com/ichi2/libanki/Media.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ open class Media(
100100
// FIXME: this also provides trash count, but UI can not handle it yet
101101
fun check(): MediaCheckResult {
102102
val out = col.backend.checkMedia()
103-
return MediaCheckResult(out.missingList, out.unusedList, listOf(), out.missingMediaNotesList)
103+
return MediaCheckResult(
104+
missingFileNames = out.missingList,
105+
unusedFileNames = out.unusedList,
106+
invalidFileNames = listOf(),
107+
missingMediaNotes = out.missingMediaNotesList,
108+
)
104109
}
105110

106111
/**

0 commit comments

Comments
 (0)