diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfcde337..50457124 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10713 - versionName = "1.7.0-beta.14" + versionCode = 10714 + versionName = "1.7.0-beta.15" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt new file mode 100644 index 00000000..9b8f9cc5 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt @@ -0,0 +1,159 @@ +package dev.typetype.android.core.ui.share + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import dev.typetype.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ShareChooserSheet( + serverBaseUrl: String?, + videoUrl: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val choices = buildShareChoices(serverBaseUrl, videoUrl) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.video_menu_share_sheet_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + text = stringResource(R.string.video_menu_share_sheet_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + choices.forEach { choice -> + ShareTargetRow( + choice = choice, + onClick = { + shareUrl(context, choice.url) + onDismiss() + }, + ) + } + } + } +} + +@Composable +private fun ShareTargetRow( + choice: ShareChoice, + onClick: () -> Unit, +) { + val isTypeType = choice.target == ShareTarget.TypeType + val providerName = choice.providerName.orEmpty() + val iconResource = when { + isTypeType -> R.drawable.ic_typetype_brand + providerName == "YouTube" -> R.drawable.ic_service_youtube + providerName == "NicoNico" -> R.drawable.ic_service_niconico + else -> R.drawable.ic_service_bilibili + } + val iconBackground = when { + isTypeType -> MaterialTheme.colorScheme.surfaceVariant + providerName == "YouTube" -> Color(0xFFCC0000) + providerName == "NicoNico" -> Color(0xFFCC6688) + else -> Color(0xFFEA4C89) + } + val title = if (isTypeType) { + stringResource(R.string.video_menu_share_target_typetype) + } else { + stringResource(R.string.video_menu_share_target_source, providerName) + } + val subtitle = if (isTypeType) { + stringResource(R.string.video_menu_share_target_typetype_description) + } else { + stringResource(R.string.video_menu_share_target_source_description, providerName) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(14.dp)) + .background(iconBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(iconResource), + contentDescription = null, + tint = if (isTypeType) Color.Unspecified else Color.White, + modifier = Modifier.size(28.dp), + ) + } + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun shareUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + context.startActivity( + Intent.createChooser(intent, context.getString(R.string.video_menu_share_chooser)), + ) +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt index 25d473b5..9fbb1c6f 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt @@ -1,19 +1,6 @@ package dev.typetype.android.core.ui.share -import android.app.AlertDialog -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView import androidx.compose.runtime.compositionLocalOf -import dev.typetype.android.R import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.toPublicWatchParameter import java.net.URI @@ -36,89 +23,47 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String { fun buildSourceShareUrl(videoUrl: String): String = resolveIncomingVideoUrl(videoUrl) ?: videoUrl.trim() -fun showShareChooser( - context: Context, +internal enum class ShareTarget { + TypeType, + Source, +} + +internal data class ShareChoice( + val target: ShareTarget, + val url: String, + val providerName: String? = null, +) + +internal fun buildShareChoices( serverBaseUrl: String?, videoUrl: String, - chooserTitle: String, -) { +): List { val sourceUrl = buildSourceShareUrl(videoUrl) val typeTypeUrl = buildShareUrl(serverBaseUrl, videoUrl) - val choices = buildList { - add( - ShareChoice( - context.getString(R.string.video_menu_share_typetype), - typeTypeUrl, - android.R.drawable.ic_menu_share, - ), - ) - providerChoice(context, sourceUrl)?.takeIf { it.url != typeTypeUrl }?.let(::add) - } - AlertDialog.Builder(context) - .setTitle(R.string.video_menu_share) - .setAdapter(ShareChoiceAdapter(context, choices)) { _, which -> - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, choices[which].url) - } - context.startActivity(Intent.createChooser(intent, chooserTitle)) + return buildList { + add(ShareChoice(target = ShareTarget.TypeType, url = typeTypeUrl)) + sourceProvider(sourceUrl)?.takeIf { sourceUrl != typeTypeUrl }?.let { provider -> + add( + ShareChoice( + target = ShareTarget.Source, + url = sourceUrl, + providerName = provider, + ), + ) } - .show() + } } -private data class ShareChoice(val label: String, val url: String, val icon: Int) - -private fun providerChoice(context: Context, sourceUrl: String): ShareChoice? { +private fun sourceProvider(sourceUrl: String): String? { val provider = sourceUrl.lowercase() return when { - "youtube.com" in provider || "youtu.be" in provider -> ShareChoice( - context.getString(R.string.video_menu_share_source, "YouTube"), - sourceUrl, - R.drawable.ic_service_youtube, - ) - "nicovideo.jp" in provider || "nico.ms" in provider -> ShareChoice( - context.getString(R.string.video_menu_share_source, "NicoNico"), - sourceUrl, - R.drawable.ic_service_niconico, - ) - "bilibili.com" in provider || "b23.tv" in provider -> ShareChoice( - context.getString(R.string.video_menu_share_source, "BiliBili"), - sourceUrl, - R.drawable.ic_service_bilibili, - ) + "youtube.com" in provider || "youtu.be" in provider -> "YouTube" + "nicovideo.jp" in provider || "nico.ms" in provider -> "NicoNico" + "bilibili.com" in provider || "b23.tv" in provider -> "BiliBili" else -> null } } -private class ShareChoiceAdapter( - private val context: Context, - private val choices: List, -) : BaseAdapter() { - override fun getCount(): Int = choices.size - override fun getItem(position: Int): ShareChoice = choices[position] - override fun getItemId(position: Int): Long = position.toLong() - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val choice = getItem(position) - val density = context.resources.displayMetrics.density - val row = (convertView as? LinearLayout) ?: LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - background = ColorDrawable(Color.TRANSPARENT) - val icon = ImageView(context) - addView(icon, LinearLayout.LayoutParams((28 * density).toInt(), (28 * density).toInt())) - val label = TextView(context).apply { - setTextAppearance(android.R.style.TextAppearance_Material_Body1) - } - addView(label, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) - } - row.setPadding((20 * density).toInt(), (12 * density).toInt(), (20 * density).toInt(), (12 * density).toInt()) - (row.getChildAt(0) as ImageView).setImageResource(choice.icon) - (row.getChildAt(1) as TextView).text = choice.label - return row - } -} - fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String { val source = imageUrl.trim().let { value -> if (value.startsWith("httpss://")) "https://${value.removePrefix("httpss://")}" else value diff --git a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt index 4ecfd202..da1d0158 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -23,7 +22,7 @@ import dev.typetype.android.core.ui.components.LocalAppSnackbarHost import dev.typetype.android.core.ui.components.PlaylistVideoActionsSheet import dev.typetype.android.core.ui.components.PlaylistVideoCard import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.showShareChooser +import dev.typetype.android.core.ui.share.ShareChooserSheet import dev.typetype.android.domain.library.PlaylistVideo import dev.typetype.android.feature.library.components.rememberVideoMetas import dev.typetype.android.feature.menu.VideoMenuEvent @@ -67,10 +66,9 @@ fun PlaylistContextTab( EmptyTab(emptyMessageFor(filter, emptyDefault)) return } - val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current var pendingMenu by remember { mutableStateOf(null) } + var shareVideoUrl by remember { mutableStateOf(null) } val urlsMissingInfo = items .filter { it.channelAvatarUrl.isBlank() || it.channelName.isBlank() } .map { it.url } @@ -102,11 +100,16 @@ fun PlaylistContextTab( onAddToQueue = { onAddToQueue(video) }, onRemoveFromList = { onRemove(video) }, onToggleWatched = { onToggleWatched(video, video.url in watchedUrls) }, - onShare = { - showShareChooser(context, serverBaseUrl, video.url, shareChooserTitle) - }, + onShare = { shareVideoUrl = video.url }, onBlockVideo = { onBlockVideo(video) }, onDismiss = { pendingMenu = null }, ) } + shareVideoUrl?.let { videoUrl -> + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = videoUrl, + onDismiss = { shareVideoUrl = null }, + ) + } } diff --git a/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt b/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt index 84db207a..98243226 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -40,7 +39,7 @@ import dev.typetype.android.feature.menu.VideoMenuHandlerViewModel import dev.typetype.android.feature.menu.blockVideoUrl import dev.typetype.android.feature.menu.removeFromPlaylist import dev.typetype.android.feature.menu.toggleWatchedUrl -import dev.typetype.android.core.ui.share.showShareChooser +import dev.typetype.android.core.ui.share.ShareChooserSheet @Composable fun PlaylistRoute( @@ -194,10 +193,9 @@ internal fun PlaylistScreen( } } } - val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = dev.typetype.android.core.ui.share.LocalServerBaseUrl.current var pendingMenu by remember { mutableStateOf(null) } + var shareVideoUrl by remember { mutableStateOf(null) } val urlsMissingInfo = visible .filter { it.channelAvatarUrl.isBlank() || it.channelName.isBlank() } .map { it.url } @@ -252,12 +250,17 @@ internal fun PlaylistScreen( isCurrentlyWatched = video.url in watchedUrls, ) }, - onShare = { - showShareChooser(context, serverBaseUrl, video.url, shareChooserTitle) - }, + onShare = { shareVideoUrl = video.url }, onBlockVideo = { menuVm.blockVideoUrl(video.url) }, onDismiss = { pendingMenu = null }, ) } + shareVideoUrl?.let { videoUrl -> + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = videoUrl, + onDismiss = { shareVideoUrl = null }, + ) + } } } diff --git a/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt b/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt index ede80d50..3a6390e2 100644 --- a/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt +++ b/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -18,7 +17,7 @@ import dev.typetype.android.core.ui.components.LocalAppSnackbarHost import dev.typetype.android.core.ui.components.VideoMenuAction import dev.typetype.android.core.ui.components.VideoMenuItemState import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.showShareChooser +import dev.typetype.android.core.ui.share.ShareChooserSheet import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.navigation.canonicalVideoIdentity import dev.typetype.android.domain.actions.titleMatchesBlockedKeyword @@ -51,7 +50,6 @@ class VideoMenuScope internal constructor( fun rememberVideoMenuScope( onOpenChannel: (channelUrl: String) -> Unit, ): VideoMenuScope { - val context = LocalContext.current val viewModel: VideoMenuHandlerViewModel = hiltViewModel() val playlists by viewModel.playlists.collectAsStateWithLifecycle() val favorites by viewModel.favoriteUrls.collectAsStateWithLifecycle() @@ -60,11 +58,11 @@ fun rememberVideoMenuScope( val blockedVideos by viewModel.blockedVideoUrls.collectAsStateWithLifecycle() val blockedChannels by viewModel.blockedChannelUrls.collectAsStateWithLifecycle() val blockedKeywords by viewModel.blockedKeywords.collectAsStateWithLifecycle() - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current var pickerVideo by remember { mutableStateOf(null) } var downloadVideo by remember { mutableStateOf(null) } + var shareVideoUrl by remember { mutableStateOf(null) } val effectiveHost: SnackbarHostState = LocalAppSnackbarHost.current ?: remember { SnackbarHostState() } @@ -79,6 +77,14 @@ fun rememberVideoMenuScope( } } + shareVideoUrl?.let { videoUrl -> + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = videoUrl, + onDismiss = { shareVideoUrl = null }, + ) + } + pickerVideo?.let { video -> PlaylistPickerSheet( playlists = playlists, @@ -124,9 +130,7 @@ fun rememberVideoMenuScope( canonicalVideoIdentity(video.url) in watched, ) VideoMenuAction.Download -> downloadVideo = video - VideoMenuAction.Share -> { - showShareChooser(context, serverBaseUrl, video.url, shareChooserTitle) - } + VideoMenuAction.Share -> shareVideoUrl = video.url VideoMenuAction.OpenChannel -> onOpenChannel(video.uploaderUrl) VideoMenuAction.BlockVideo -> viewModel.blockVideo(video) VideoMenuAction.BlockChannel -> viewModel.blockChannel(video) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt index cbcc5bac..5b9ee373 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt @@ -22,14 +22,17 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.showShareChooser +import dev.typetype.android.core.ui.share.ShareChooserSheet @Composable fun PlayerInteractionRow( @@ -47,9 +50,8 @@ fun PlayerInteractionRow( audioOnlyChanging: Boolean = false, onToggleAudioOnly: () -> Unit = {}, ) { - val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current + var shareSheetOpen by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -102,9 +104,14 @@ fun PlayerInteractionRow( PlayerActionButton( icon = Icons.Filled.Share, contentDescription = stringResource(R.string.video_menu_share), - onClick = { - showShareChooser(context, serverBaseUrl, shareUrl, shareChooserTitle) - }, + onClick = { shareSheetOpen = true }, + ) + } + if (shareSheetOpen) { + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = shareUrl, + onDismiss = { shareSheetOpen = false }, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 301ad0a3..9ea579f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -923,6 +923,12 @@ Share via Share TypeType link Share %1$s link + Share this video + Choose which link you want to share. + TypeType + %1$s + Open with this TypeType instance + Open the original %1$s video Video actions Open %1$s channel Open %1$s diff --git a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt index 04885088..fdb62230 100644 --- a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt +++ b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt @@ -48,6 +48,40 @@ class ShareUrlsTest { ) } + @Test + fun offersTypeTypeAndOriginalProviderLinks() { + assertEquals( + listOf( + ShareChoice( + target = ShareTarget.TypeType, + url = "https://watch.example/watch?v=dQw4w9WgXcQ", + ), + ShareChoice( + target = ShareTarget.Source, + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + providerName = "YouTube", + ), + ), + buildShareChoices( + "https://watch.example/api", + "https://youtube.com/watch?v=dQw4w9WgXcQ", + ), + ) + } + + @Test + fun offersOnlyTypeTypeLinkForUnknownProvider() { + assertEquals( + listOf( + ShareChoice( + target = ShareTarget.TypeType, + url = "https://watch.example/watch?v=https%3A%2F%2Fexample.com%2Fvideo", + ), + ), + buildShareChoices("https://watch.example/api", "https://example.com/video"), + ) + } + @Test fun proxiesRemoteImagesThroughTheActiveServer() { assertEquals(