diff --git a/app/build.gradle b/app/build.gradle index e2d71bc1d..eb4773aea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -210,6 +210,8 @@ dependencies { implementation 'androidx.compose.ui:ui-tooling-preview' debugImplementation 'androidx.compose.ui:ui-tooling' testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.compose.ui:ui-test-junit4' + testImplementation 'androidx.work:work-testing:2.11.2' testImplementation 'org.robolectric:robolectric:4.17' testImplementation 'com.squareup.okhttp3:mockwebserver3:5.5.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0' diff --git a/app/src/androidTest/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenTest.kt b/app/src/androidTest/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenTest.kt index a42c00240..a8d3d0219 100644 --- a/app/src/androidTest/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenTest.kt +++ b/app/src/androidTest/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenTest.kt @@ -42,6 +42,8 @@ class BlocklistsScreenTest { BlocklistsScreenContent( model = BlocklistsScreenModel(listOf(first, second)), callbacks = object : BlocklistsScreenCallbacks { + override fun onDownload() = Unit + override fun onAdd() { addClicks++ } @@ -90,6 +92,8 @@ class BlocklistsScreenTest { BlocklistsScreenContent( model = BlocklistsScreenModel(emptyList()), callbacks = object : BlocklistsScreenCallbacks { + override fun onDownload() = Unit + override fun onAdd() { addClicks++ } diff --git a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java index f83c8d63a..d1f71804a 100644 --- a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java +++ b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java @@ -408,6 +408,15 @@ public boolean onPreferenceClick(Preference preference) { }); } + // Custom lists cannot affect the Play build's Minimal-only policy. + if (BlockingMode.isPlayStoreBuild() && cat_advanced != null) { + for (String key : new String[]{"manage_blocklists", "hosts_download", "hosts_auto_update"}) { + Preference preference = cat_advanced.findPreference(key); + if (preference != null) + cat_advanced.removePreference(preference); + } + } + // Hosts file settings if (cat_advanced != null) { Preference pref_use_hosts = screen.findPreference("use_hosts"); diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java index 4c10ee670..c93cc5c22 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java +++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityBlocklists.java @@ -9,11 +9,19 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.compose.ui.platform.ComposeView; +import androidx.preference.PreferenceManager; +import androidx.work.Constraints; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import net.kollnig.missioncontrol.data.Blocklist; import net.kollnig.missioncontrol.data.BlocklistManager; +import net.kollnig.missioncontrol.data.BlockingMode; import net.kollnig.missioncontrol.ui.compose.BlocklistRow; import net.kollnig.missioncontrol.ui.compose.BlocklistsScreen; import net.kollnig.missioncontrol.ui.compose.BlocklistsScreenCallbacks; @@ -28,16 +36,24 @@ import java.util.List; import eu.faircode.netguard.ServiceSinkhole; +import eu.faircode.netguard.HostsDownloadWorker; import eu.faircode.netguard.Util; public class ActivityBlocklists extends AppCompatActivity { + private static final String DOWNLOAD_WORK = "CustomBlocklistsDownload"; private BlocklistManager manager; private BlocklistsScreenController screenController; + private boolean downloading; + private boolean downloadRunning; @Override protected void onCreate(Bundle savedInstanceState) { Util.setTheme(this); super.onCreate(savedInstanceState); + if (BlockingMode.isPlayStoreBuild()) { + finish(); + return; + } ComposeView composeView = new ComposeView(this); // A stable ID gives Compose's saveable state registry a key to persist @@ -74,9 +90,29 @@ public void onEnabledChanged(String uuid, boolean enabled) { public void onDelete(String uuid) { showDeleteDialog(uuid); } + + @Override + public void onDownload() { + downloadBlocklists(); + } + }); + WorkManager.getInstance(this).getWorkInfosForUniqueWorkLiveData(DOWNLOAD_WORK) + .observe(this, workInfos -> { + downloading = workInfos != null && workInfos.stream() + .anyMatch(info -> !info.getState().isFinished()); + downloadRunning = workInfos != null && workInfos.stream() + .anyMatch(info -> info.getState() == WorkInfo.State.RUNNING); + refreshScreen(); }); } + @Override + protected void onResume() { + super.onResume(); + if (screenController != null) + refreshScreen(); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { @@ -89,13 +125,15 @@ public boolean onOptionsItemSelected(MenuItem item) { private BlocklistsScreenModel buildScreenModel() { List rows = new ArrayList<>(); for (Blocklist item : manager.getBlocklists()) { - String lastUpdate = null; + String lastUpdate = getString(R.string.blocklist_not_downloaded); if (item.lastModified > 0) { String last = SimpleDateFormat.getDateTimeInstance().format(new Date(item.lastModified)); lastUpdate = getString(R.string.msg_last_update, last); } - String error = item.lastDownloadSuccess ? null : item.lastErrorMessage; + String error = item.lastDownloadSuccess ? null : + (item.lastErrorMessage == null ? getString(R.string.blocklist_download_failed) + : item.lastErrorMessage); rows.add(new BlocklistRow( item.uuid, item.url, @@ -105,7 +143,12 @@ private BlocklistsScreenModel buildScreenModel() { getString(R.string.blocklist_enable_description, item.url), getString(R.string.blocklist_delete_description))); } - return new BlocklistsScreenModel(rows); + String notice = null; + if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("log_logcat", false)) + notice = getString(R.string.blocklist_research_notice); + else if (BlockingMode.isMinimalMode(this)) + notice = getString(R.string.blocklist_minimal_notice); + return new BlocklistsScreenModel(rows, notice, downloading, downloadRunning); } private void refreshScreen() { @@ -131,6 +174,7 @@ private void showAddDialog(String uuid) { AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); builder.setTitle(uuid == null ? R.string.title_add_blocklist : R.string.title_blocklists); + builder.setMessage(R.string.blocklist_format); final EditText input = new EditText(this); input.setInputType(InputType.TYPE_TEXT_VARIATION_URI); @@ -159,13 +203,14 @@ private void showAddDialog(String uuid) { item.lastModified = 0; item.lastDownloadSuccess = true; item.lastErrorMessage = null; + item.etag = null; + item.lastModifiedHeader = null; manager.getBlocklistFile(item.uuid).delete(); } item.url = url; manager.updateBlocklist(item); } - applyBlocklists(); - refreshScreen(); + downloadBlocklists(); } catch (MalformedURLException e) { Toast.makeText(ActivityBlocklists.this, R.string.msg_invalid_url, Toast.LENGTH_SHORT).show(); } @@ -185,6 +230,8 @@ private void updateEnabled(String uuid, boolean enabled) { manager.updateBlocklist(item); applyBlocklists(); refreshScreen(); + if (enabled && !manager.getBlocklistFile(uuid).exists()) + downloadBlocklists(); } private void showDeleteDialog(String uuid) { @@ -214,4 +261,20 @@ private void applyBlocklists() { ServiceSinkhole.reload("blocklist changed", this, false); } + + private void downloadBlocklists() { + OneTimeWorkRequest work = new OneTimeWorkRequest.Builder(HostsDownloadWorker.class) + .setConstraints(new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build(); + // Serialise requests from this screen; an offline add remains queued. + WorkManager workManager = WorkManager.getInstance(this); + workManager.enqueueUniqueWork(DOWNLOAD_WORK, ExistingWorkPolicy.APPEND_OR_REPLACE, work); + downloading = true; + refreshScreen(); + workManager.getWorkInfoByIdLiveData(work.getId()).observe(this, info -> { + if (info != null && info.getState() == WorkInfo.State.FAILED) + Toast.makeText(this, R.string.blocklist_download_failed, Toast.LENGTH_LONG).show(); + }); + } } diff --git a/app/src/main/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreen.kt b/app/src/main/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreen.kt index 5e68d9510..4a7110aee 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreen.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -41,6 +42,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -61,8 +63,11 @@ data class BlocklistRow( /** Immutable presentation model for the Blocklists screen. */ @Immutable -data class BlocklistsScreenModel( - val rows: List +data class BlocklistsScreenModel @JvmOverloads constructor( + val rows: List, + val notice: String? = null, + val downloading: Boolean = false, + val downloadRunning: Boolean = false ) /** Callback boundary for persistence and dialogs owned by ActivityBlocklists. */ @@ -71,6 +76,7 @@ interface BlocklistsScreenCallbacks { fun onEdit(uuid: String) fun onEnabledChanged(uuid: String, enabled: Boolean) fun onDelete(uuid: String) + fun onDownload() } /** Handle used by ActivityBlocklists to refresh the Compose model. */ @@ -115,10 +121,14 @@ internal fun BlocklistsScreenContent( contentWindowInsets = WindowInsets.safeDrawing, floatingActionButton = { FloatingActionButton( - onClick = callbacks::onAdd, - modifier = Modifier.testTag("blocklist-add"), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + onClick = { if (!model.downloadRunning) callbacks.onAdd() }, + modifier = Modifier.testTag("blocklist-add").semantics { + if (model.downloadRunning) disabled() + }, + containerColor = if (model.downloadRunning) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.primary, + contentColor = if (model.downloadRunning) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary ) { Icon( painter = painterResource(android.R.drawable.ic_input_add), @@ -127,10 +137,38 @@ internal fun BlocklistsScreenContent( } } ) { contentPadding -> - if (model.rows.isEmpty()) { - EmptyBlocklistsContent(contentPadding) - } else { - BlocklistsList(model.rows, contentPadding, callbacks) + Column(Modifier.fillMaxSize().padding(contentPadding)) { + model.notice?.let { notice -> + Text( + text = notice, + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.secondaryContainer) + .padding(16.dp).testTag("blocklist-mode-notice"), + color = MaterialTheme.colorScheme.onSecondaryContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + if (model.notice == null) { + Text( + stringResource(R.string.blocklist_browser_notice), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + } + if (model.downloading) { + Text( + stringResource(R.string.blocklist_downloading), + modifier = Modifier.padding(16.dp).testTag("blocklist-downloading"), + style = MaterialTheme.typography.bodyMedium + ) + } + TextButton(onClick = callbacks::onDownload, enabled = !model.downloading && model.rows.any { it.enabled }) { + Text(stringResource(R.string.blocklist_update)) + } + if (model.rows.isEmpty()) { + EmptyBlocklistsContent(PaddingValues()) + } else { + BlocklistsList(model.rows, PaddingValues(), callbacks, !model.downloadRunning) + } } } } @@ -167,7 +205,8 @@ private fun EmptyBlocklistsContent(contentPadding: PaddingValues) { private fun BlocklistsList( rows: List, contentPadding: PaddingValues, - callbacks: BlocklistsScreenCallbacks + callbacks: BlocklistsScreenCallbacks, + enabled: Boolean ) { LazyColumn( modifier = Modifier @@ -179,7 +218,7 @@ private fun BlocklistsList( items = rows, key = { _, row -> row.uuid } ) { index, row -> - BlocklistListItem(row, callbacks) + BlocklistListItem(row, callbacks, enabled) if (index < rows.lastIndex) { HorizontalDivider( modifier = Modifier.testTag("blocklist-divider"), @@ -193,7 +232,8 @@ private fun BlocklistsList( @Composable private fun BlocklistListItem( row: BlocklistRow, - callbacks: BlocklistsScreenCallbacks + callbacks: BlocklistsScreenCallbacks, + enabled: Boolean ) { ListItem( headlineContent = { @@ -232,6 +272,7 @@ private fun BlocklistListItem( ) { Switch( checked = row.enabled, + enabled = enabled, onCheckedChange = { enabled -> callbacks.onEnabledChanged(row.uuid, enabled) }, @@ -243,6 +284,7 @@ private fun BlocklistListItem( ) IconButton( onClick = { callbacks.onDelete(row.uuid) }, + enabled = enabled, modifier = Modifier.testTag("blocklist-delete-${row.uuid}") ) { Icon( @@ -254,7 +296,7 @@ private fun BlocklistListItem( }, modifier = Modifier .fillMaxWidth() - .clickable { callbacks.onEdit(row.uuid) } + .clickable(enabled = enabled) { callbacks.onEdit(row.uuid) } .testTag("blocklist-row-${row.uuid}") ) } @@ -291,6 +333,7 @@ private fun BlocklistsScreenPreview() { override fun onEdit(uuid: String) = Unit override fun onEnabledChanged(uuid: String, enabled: Boolean) = Unit override fun onDelete(uuid: String) = Unit + override fun onDownload() = Unit } ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d401c1e41..e7aae0a28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -823,15 +823,24 @@ Sincerely,\n\n]]> What\'s New • Blocking Modes: Choose between Minimal, Standard, or Strict blocking to balance privacy and app compatibility.\n• Minimal Mode: DuckDuckGo-compatible blocking that avoids app breakage. Browsers stay routed through the VPN, but tracker protection is off by default; known incompatible apps are excluded.\n• Privacy Fix: SNI extraction is now off by default — it was leaking your IP address to tracker servers.\n• Improved Performance: Faster scrolling, reduced memory usage, and better battery life.\n• Troubleshooting: New troubleshooting screen in settings to diagnose common issues.\n• Better Tracker Detection: Improved handling of shared IPs and DNS-cloaked trackers. - Additional host files - These are only for fallback. TrackerControl includes its own host lists that give insights on the companies behind tracking. - Add host file - Delete host file - Are you sure you want to delete this host file? + Custom blocklists + Add blocklists in hosts-file format. Used when the built-in tracker lists do not identify a domain. + Add blocklist + Delete blocklist + Are you sure you want to delete this blocklist? Invalid URL - Failed to apply host files + Failed to apply blocklists Last update: %s + Enter an HTTPS URL for a hosts-format blocklist (for example, 0.0.0.0 ads.example.com). Browser filter rules are not supported. + Custom blocklists are inactive in Minimal mode. Switch to Standard or Strict in Settings to use them. + Custom blocklists are inactive while Research mode is on. Turn off Research in Settings to enable tracker blocking. + Browser tracker protection is off by default. Enable it on the browser’s app page to use custom blocklists there. + Downloading blocklists… Waiting for a connection if offline. + Not downloaded yet + Could not download or apply blocklists. Check the list errors and try updating again. + Update blocklists + Tracker blocking VPN diff --git a/app/src/test/java/net/kollnig/missioncontrol/ActivityBlocklistsTest.kt b/app/src/test/java/net/kollnig/missioncontrol/ActivityBlocklistsTest.kt new file mode 100644 index 000000000..3929da871 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/ActivityBlocklistsTest.kt @@ -0,0 +1,125 @@ +package net.kollnig.missioncontrol + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.NetworkType +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import net.kollnig.missioncontrol.data.BlockingMode +import net.kollnig.missioncontrol.data.BlocklistManager +import net.kollnig.missioncontrol.ui.compose.BlocklistsScreenModel +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowDialog + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ActivityBlocklistsTest { + private val context get() = ApplicationProvider.getApplicationContext() + + @Before fun setupWork() { + WorkManagerTestInitHelper.initializeTestWorkManager(context, + Configuration.Builder().setExecutor(SynchronousExecutor()).build()) + } + + @Test fun addingListQueuesConstrainedDownloadWithoutReplacingCachedHosts() { + org.junit.Assume.assumeFalse(BlockingMode.isPlayStoreBuild()) + val cached = java.io.File(context.filesDir, "hosts.txt") + cached.writeText("0.0.0.0 old.example\n") + val controller = Robolectric.buildActivity(ActivityBlocklists::class.java).setup() + val activity = controller.get() + addUrl(activity, "https://example.com/custom-hosts") + val work = WorkManager.getInstance(context) + .getWorkInfosForUniqueWork("CustomBlocklistsDownload").get() + assertEquals(1, work.size) + assertEquals(NetworkType.CONNECTED, work.single().constraints.requiredNetworkType) + assertFalse(work.single().state.isFinished) + assertTrue(BlocklistManager.getInstance(context).blocklists.any { + it.url == "https://example.com/custom-hosts" + }) + assertEquals("0.0.0.0 old.example\n", cached.readText()) + controller.recreate() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + assertTrue(model(controller.get()).downloading) + controller.pause().stop().destroy() + } + + @Test fun noticesRespectBuildAndResearchOverride() { + val controller = Robolectric.buildActivity(ActivityBlocklists::class.java).setup() + val activity = controller.get() + if (BlockingMode.isPlayStoreBuild()) { + assertTrue(activity.isFinishing) + assertTrue(WorkManager.getInstance(context) + .getWorkInfosForUniqueWork("CustomBlocklistsDownload").get().isEmpty()) + controller.pause().stop().destroy() + return + } + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putString("blocking_mode", "minimal").putBoolean("log_logcat", false).commit() + assertEquals(activity.getString(R.string.blocklist_minimal_notice), model(activity).notice) + prefs.edit().putString("blocking_mode", "strict").putBoolean("log_logcat", true).commit() + assertEquals(activity.getString(R.string.blocklist_research_notice), model(activity).notice) + prefs.edit().putBoolean("log_logcat", false).commit() + assertNull(model(activity).notice) + controller.pause().stop().destroy() + } + + @Test fun settingsExposeBlocklistsOnlyOutsidePlayIncludingAdvancedSubscreen() { + val controller = Robolectric.buildActivity(eu.faircode.netguard.ActivitySettings::class.java).setup() + val activity = controller.get() + fun screen(): androidx.preference.PreferenceScreen { + activity.supportFragmentManager.executePendingTransactions() + return (activity.supportFragmentManager.findFragmentById(R.id.settings_container) + as androidx.preference.PreferenceFragmentCompat).preferenceScreen + } + fun checkControls() { + for (key in listOf("manage_blocklists", "hosts_download", "hosts_auto_update")) { + val preference = screen().findPreference(key) + if (BlockingMode.isPlayStoreBuild()) assertNull(key, preference) + else assertNotNull(key, preference) + } + } + checkControls() + val advanced = screen().findPreference("screen_advanced_options")!! + val fragment = activity.supportFragmentManager.findFragmentById(R.id.settings_container) + as androidx.preference.PreferenceFragmentCompat + activity.onPreferenceStartScreen(fragment, advanced) + activity.supportFragmentManager.executePendingTransactions() + checkControls() + controller.pause().stop().destroy() + } + + private fun model(activity: ActivityBlocklists): BlocklistsScreenModel = + ActivityBlocklists::class.java.getDeclaredMethod("buildScreenModel").apply { + isAccessible = true + }.invoke(activity) as BlocklistsScreenModel + + private fun addUrl(activity: ActivityBlocklists, url: String) { + ActivityBlocklists::class.java.getDeclaredMethod("showAddDialog", String::class.java).apply { + isAccessible = true + }.invoke(activity, null) + val dialog = ShadowDialog.getLatestDialog() as AlertDialog + findInput(dialog.window!!.decorView)!!.setText(url) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + } + + private fun findInput(view: View): EditText? { + if (view is EditText) return view + if (view is ViewGroup) for (i in 0 until view.childCount) + findInput(view.getChildAt(i))?.let { return it } + return null + } +} diff --git a/app/src/test/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenNoticeTest.kt b/app/src/test/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenNoticeTest.kt new file mode 100644 index 000000000..53b9d9c36 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/ui/compose/BlocklistsScreenNoticeTest.kt @@ -0,0 +1,91 @@ +package net.kollnig.missioncontrol.ui.compose + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import android.content.Context +import net.kollnig.missioncontrol.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "w320dp-h640dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class BlocklistsScreenNoticeTest { + @get:Rule val compose = createComposeRule() + private val context get() = ApplicationProvider.getApplicationContext() + private var updates = 0 + private val callbacks = object : BlocklistsScreenCallbacks { + override fun onAdd() = Unit + override fun onEdit(uuid: String) = Unit + override fun onEnabledChanged(uuid: String, enabled: Boolean) = Unit + override fun onDelete(uuid: String) = Unit + override fun onDownload() { updates++ } + } + private fun row(error: String? = null) = BlocklistRow( + "one", "https://example.com/hosts.txt", "Not downloaded yet", error, + true, "Enable blocklist", "Delete blocklist" + ) + + @Test fun minimalNoticeAndDownloadErrorsRemainVisibleWithRetry() { + compose.setContent { + TrackerControlTheme { + BlocklistsScreenContent(BlocklistsScreenModel(listOf(row("HTTP 404")), + context.getString(R.string.blocklist_minimal_notice)), callbacks) + } + } + compose.onNodeWithTag("blocklist-mode-notice").assertIsDisplayed() + compose.onNodeWithText("HTTP 404").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.blocklist_update)).performClick() + assertEquals(1, updates) + capture("blocklists-minimal") + } + + @Test fun minimalNoticeRemainsVisibleEvenWithNoLists() { + compose.setContent { + TrackerControlTheme { + BlocklistsScreenContent(BlocklistsScreenModel(emptyList(), + context.getString(R.string.blocklist_minimal_notice)), callbacks) + } + } + compose.onNodeWithText(context.getString(R.string.blocklist_minimal_notice)).assertIsDisplayed() + compose.onNodeWithTag("blocklist-add").assertIsDisplayed() + capture("blocklists-empty") + } + + @Test fun runningDownloadDisablesRetryAndListMutation() { + compose.setContent { + TrackerControlTheme { + BlocklistsScreenContent(BlocklistsScreenModel(listOf(row()), null, true, true), callbacks) + } + } + compose.onNodeWithTag("blocklist-downloading").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.blocklist_update)).assertIsNotEnabled() + compose.onNodeWithTag("blocklist-add").assertIsNotEnabled() + compose.onNodeWithTag("blocklist-switch-one").assertIsNotEnabled() + compose.onNodeWithTag("blocklist-delete-one").assertIsNotEnabled() + capture("blocklists-downloading") + } + + private fun capture(name: String) { + val output = File("build/reports/$name.png") + output.parentFile!!.mkdirs() + output.outputStream().use { + compose.onRoot().captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it) + } + } +}