diff --git a/README.md b/README.md index f9880972..1c4db971 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,6 @@ Powered by multiple image boards and boorus, Breadboard makes finding your favou Alternatively, download [the latest GitHub release](https://github.com/breadboardapp/breadboard/releases/latest), allow unknown sources in your device settings, and then install manually. This method will not provide automatic/notifications for updates. -> [!WARNING] -> Legacy builds (with the `-legacy` suffix) are deprecated we will cease publishing them in the near future. -> -> Make sure you download a standard release (with no suffix). - ## Contributing Feedback and code contributions are welcome! diff --git a/app/build.gradle b/app/build.gradle index e029853c..c7d34750 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,14 +14,14 @@ if (rootProject.file("local.properties").exists()) { android { namespace 'moe.apex.breadboard' - compileSdk 36 + compileSdk 37 defaultConfig { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 321 - versionName "3.2.1" + versionCode 323 + versionName "3.2.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt b/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt index e18fb404..457ee2ce 100644 --- a/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt @@ -10,6 +10,8 @@ import android.view.KeyEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.ComposeMaterial3Flags +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState @@ -58,10 +60,14 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu } + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + // https://issuetracker.google.com/issues/521534697 TODO + ComposeMaterial3Flags.isBottomSheetPartiallyExpandedDeterministicEnabled = false + applicationContext.preferencesDataStoreFile("preferences") runBlocking { prefs.handleMigration(applicationContext) } val initialPrefs = runBlocking { prefs.getPreferences.first() } diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index aaab13e8..db5ef6f6 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - package moe.apex.breadboard import android.content.Context @@ -13,6 +11,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ComposeMaterial3Flags import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -80,12 +79,14 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu } - @OptIn(ExperimentalFoundationApi::class) + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) // Having this enabled seems to result in some items becoming invisible when animating lazy lists ComposeFoundationFlags.isSkipItemPlacementAnimationFixEnabled = false + // https://issuetracker.google.com/issues/521534697 TODO + ComposeMaterial3Flags.isBottomSheetPartiallyExpandedDeterministicEnabled = false applicationContext.preferencesDataStoreFile("preferences") runBlocking { prefs.handleMigration(applicationContext) } diff --git a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt index c35830d2..69b670e6 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -31,8 +31,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -49,6 +47,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.launch @@ -57,6 +57,7 @@ import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.util.NavBarHeightVerticalSpacer import moe.apex.breadboard.util.PullToRefreshController import moe.apex.breadboard.util.SMALL_SPACER +import moe.apex.breadboard.util.WideLinearWavyProgressIndicator import moe.apex.breadboard.util.largerShape @@ -66,7 +67,6 @@ private const val MIN_CELL_WIDTH = 120 private const val MAX_CELL_WIDTH = 144 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImageGrid( modifier: Modifier = Modifier, @@ -138,10 +138,7 @@ fun ImageGrid( .padding(contentPadding) .background(MaterialTheme.colorScheme.background) ) { - LinearProgressIndicator( - modifier = modifier - .fillMaxWidth() - ) + WideLinearWavyProgressIndicator(modifier = modifier.fillMaxWidth()) } LaunchedEffect(doneInitialLoad) { @@ -154,7 +151,6 @@ fun ImageGrid( } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun StaggeredImageGrid( modifier: Modifier = Modifier, @@ -313,8 +309,14 @@ private fun ImagePreview( onImageClick: (Int, Image) -> Unit ) { val context = LocalContext.current + + val headersBuilder = remember { + NetworkHeaders.Builder() + .set("Referer", image.imageSource.imageBoard.baseUrl) + } val model = remember { ImageRequest.Builder(context) .data(image.previewUrl) + .httpHeaders(headersBuilder.build()) .crossfade(true) .build() } diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index c2d4c446..69a03950 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -18,11 +18,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment @@ -30,13 +28,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import kotlinx.coroutines.launch import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.image.ImageBoardRequirement import moe.apex.breadboard.image.ImageRating -import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.navigation.Settings import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource @@ -74,7 +72,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) var isImageCarouselVisible by remember { mutableStateOf(false) } - var initialPage by remember { mutableIntStateOf(0) } + var selectedImageIndex by remember { mutableIntStateOf(0) } var showAgeVerificationDialog by remember { mutableStateOf(false) } val preferencesRepository = LocalContext.current.prefs @@ -82,47 +80,33 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li val manuallyBlockedTags by rememberUpdatedState(prefs.manuallyBlockedTags) val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) - val actuallyBlockedTags = rememberSaveable { mutableStateSetOf() } - val actuallySelectedRatings = rememberSaveable { - mutableStateSetOf().apply { - addAll(prefs.ratingsFilter) - } - } + val isReady by viewModel.isReady.collectAsStateWithLifecycle() + val viewModelAuth by viewModel.auth.collectAsStateWithLifecycle() + val doneInitialLoad by viewModel.doneInitialLoad.collectAsStateWithLifecycle() + val viewModelImages by viewModel.images.collectAsStateWithLifecycle() + val blockedTags by viewModel.blockedTags.collectAsStateWithLifecycle() + val selectedRatings by viewModel.selectedRatings.collectAsStateWithLifecycle() fun setUpViewModel(auth: ImageBoardAuth? = null) { - if (!viewModel.isReady) { - viewModel.setup( - imageSource = source, - auth = auth ?: prefs.authFor(source, context), - tags = tagList - ) - } + viewModel.setup( + imageSource = source, + auth = auth ?: prefs.authFor(source, context), + tags = tagList + ) } - /* Populate the internal list of blocked tags. - If the user explicitly searches for an AI tag, - we'll unblock all AI tag variations for this search. */ - fun updateBlockedTags() { - val blockList = if (AI_TAG_NAMES.any { it in tagList }) { - manuallyBlockedTags - } else { - manuallyBlockedTags + AI_TAG_NAMES - } - Snapshot.withMutableSnapshot { - actuallyBlockedTags.clear() - actuallyBlockedTags.addAll(blockList.filter { it !in tagList }) - } - } + fun updateBlockedTags() = viewModel.updateBlockedTags(manuallyBlockedTags, prefs.excludeAi) LaunchedEffect(Unit) { val auth = prefs.authFor(source, context) - if (auth != viewModel.auth) { - viewModel.prepareReset() + if (auth != viewModelAuth) { + viewModel.updateAuth(auth) } - setUpViewModel(auth) - // Don't automatically update on config change like screen rotation if the list is already populated - if (actuallyBlockedTags.isEmpty()) { - updateBlockedTags() + + if (!isReady) { + viewModel.updateSelectedRatings(prefs.ratingsFilter) + setUpViewModel(auth) + updateBlockedTags() // Subsequent calls are done in the pull to refresh callback. } } @@ -132,7 +116,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li modifier = Modifier .align(Alignment.TopCenter) .then( - if (prefs.filterRatingsLocally) { + if (filterLocally) { Modifier.offset(y = 80.dp) // Height of the ratings box } else Modifier ), @@ -140,41 +124,39 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li ) } ) { - updateBlockedTags() - viewModel.prepareReset() setUpViewModel() + updateBlockedTags() viewModel.loadMore() } - val ratingRows: List<@Composable () -> Unit> = availableRatingsForCurrentSource.map { { + val ratingRows: List<@Composable () -> Unit> = availableRatingsForCurrentSource.map { rating -> { FilterChip( - selected = it in actuallySelectedRatings, - label = { Text(it.label) }, + selected = rating in selectedRatings, + label = { Text(rating.label) }, colors = filterChipSolidColor, border = null, onClick = { - if (it in actuallySelectedRatings) { - actuallySelectedRatings.remove(it) + if (rating in selectedRatings) { + viewModel.removeRating(rating) } else { - if (it != ImageRating.SAFE && !AgeVerification.hasVerifiedAge(prefs)) { + if (rating != ImageRating.SAFE && !AgeVerification.hasVerifiedAge(prefs)) { showAgeVerificationDialog = true return@FilterChip } else { - actuallySelectedRatings.add(it) + viewModel.addRating(rating) } } scope.launch { preferencesRepository.updateSet( PreferenceKeys.RATINGS_FILTER, - actuallySelectedRatings.map { it.name }) + viewModel.selectedRatings.value.map { it.name }) } } ) } } - val imagesToDisplay = viewModel.images.filter { - it.metadata!!.tags.none { tag -> actuallyBlockedTags.contains(tag.lowercase()) } && - if (prefs.filterRatingsLocally) it.metadata.rating in prefs.ratingsFilter else true + val imagesToDisplay = remember(viewModelImages, blockedTags, selectedRatings) { + viewModel.filterImages(if (filterLocally) selectedRatings else null) } if (showAgeVerificationDialog) { @@ -191,7 +173,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li scrollBehavior = scrollBehavior, navController = navController, additionalActions = { - if (viewModel.isReady) { + if (doneInitialLoad) { ScrollToTopArrow( staggeredGridState = viewModel.staggeredGridState, uniformGridState = viewModel.uniformGridState, @@ -204,43 +186,27 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li addBottomPadding = false, blur = isImageCarouselVisible && blur, ) { padding -> - if (!viewModel.isReady) { - return@MainScreenScaffold - } - val needsAuth = remember { source.imageBoard.apiKeyRequirement == ImageBoardRequirement.REQUIRED && prefs.authFor(source, context) == null } if (needsAuth) { - return@MainScreenScaffold Column( + return@MainScreenScaffold ApiKeyRequiredColumn( modifier = Modifier .padding(padding) .padding(top = SMALL_LARGE_SPACER.dp) .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + source = source ) { - ExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { - TitleSummary( - title = "API Key required", - summary = "${source.label} requires an API key to search.\n" + - "Add an API key in Settings.\n" + - "Alternatively, use a different image source.", - ) - } - Button( - onClick = { - navController.navigate(Settings) - }, - colors = ButtonDefaults.buttonColors() - ) { - Text("Go to Settings") - } + navController.navigate(Settings) } } + if (!isReady) { + return@MainScreenScaffold + } + ImageGrid( modifier = Modifier .padding(padding.withoutVertical(top = false)) @@ -250,7 +216,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li images = imagesToDisplay, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { - initialPage = index + selectedImageIndex = index isImageCarouselVisible = true } }, @@ -263,21 +229,49 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li ) } } else null, pullToRefreshController = pullToRefreshController, - doneInitialLoad = viewModel.doneInitialLoad, + doneInitialLoad = doneInitialLoad, onEndReached = viewModel::loadMore, - noImagesContent = { if (viewModel.doneInitialLoad) { NoImages() } } + noImagesContent = { if (doneInitialLoad) { NoImages() } } ) } OffsetBasedLargeImageView( navController = navController, isActive = isImageCarouselVisible, - initialPage = initialPage, + initialSelectedImageIndex = selectedImageIndex, allImages = imagesToDisplay, onActiveStateChanged = { isImageCarouselVisible = it } ) { oldImage, newImage -> - val index = viewModel.images.indexOf(oldImage) - if (index != -1) viewModel.images[index] = newImage + viewModel.updateImage(oldImage, newImage) } } + + +@Composable +fun ApiKeyRequiredColumn( + modifier: Modifier = Modifier, + source: ImageSource, + onClick: () -> Unit +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + ) { + ExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { + TitleSummary( + title = "API Key required", + summary = "${source.label} requires an API key to search.\n" + + "Add an API key in Settings.\n" + + "Alternatively, use a different image source.", + ) + } + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors() + ) { + Text("Go to Settings") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt index 60b1e346..18e3f2b5 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -53,7 +53,7 @@ fun FavouritesPage( val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) var isImageCarouselVisible by remember { mutableStateOf(false) } - var initialPage by remember { mutableIntStateOf(0) } + var selectedImageIndex by remember { mutableIntStateOf(0) } val scope = rememberCoroutineScope() val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) @@ -147,7 +147,7 @@ fun FavouritesPage( images = images, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { - initialPage = index + selectedImageIndex = index isImageCarouselVisible = true } }, @@ -165,7 +165,7 @@ fun FavouritesPage( OffsetBasedLargeImageView( navController = navController, isActive = isImageCarouselVisible, - initialPage = initialPage, + initialSelectedImageIndex = selectedImageIndex, allImages = images, onActiveStateChanged = { isImageCarouselVisible = it diff --git a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index daabf9f8..351f892f 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -44,6 +43,7 @@ import moe.apex.breadboard.util.RecommendationsProvider import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.ScrollToTopArrow +import moe.apex.breadboard.util.WideLinearWavyProgressIndicator import moe.apex.breadboard.util.bottomAppBarAndNavBarHeight import moe.apex.breadboard.util.differenceOlderThan import moe.apex.breadboard.util.onScroll @@ -70,7 +70,7 @@ fun HomeScreen( val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) var shouldShowLargeImage by remember { mutableStateOf(false) } - var initialPage by remember { mutableIntStateOf(0) } + var selectedImageIndex by remember { mutableIntStateOf(0) } val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) @@ -121,7 +121,7 @@ fun HomeScreen( } if (builtInIgnoredTags.isEmpty()) { - LinearProgressIndicator( + WideLinearWavyProgressIndicator( modifier = Modifier .fillMaxWidth() .padding(padding) @@ -139,8 +139,7 @@ fun HomeScreen( initialBlockedTags = prefs.blockedTags, initialUnfollowedTags = prefs.unfollowedTags + builtInIgnoredTags, selectionSize = prefs.recommendationsTagCount, - poolSize = prefs.recommendationsPoolSize, - useWeightedSelection = prefs.recommendationsWeightedSelection + poolSize = prefs.recommendationsPoolSize ) newProvider.prepareRecommendedTags() viewModel.setRecommendationsProvider(newProvider) @@ -184,7 +183,7 @@ fun HomeScreen( }, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { - initialPage = index + selectedImageIndex = index shouldShowLargeImage = true } }, @@ -206,7 +205,7 @@ fun HomeScreen( OffsetBasedLargeImageView( navController = navController, isActive = shouldShowLargeImage, - initialPage = initialPage, + initialSelectedImageIndex = selectedImageIndex, allImages = recommendedImages ?: emptyList(), onActiveStateChanged = { shouldShowLargeImage = it diff --git a/app/src/main/java/moe/apex/breadboard/image/Image.kt b/app/src/main/java/moe/apex/breadboard/image/Image.kt index 22d6c77b..08829866 100644 --- a/app/src/main/java/moe/apex/breadboard/image/Image.kt +++ b/app/src/main/java/moe/apex/breadboard/image/Image.kt @@ -6,6 +6,7 @@ import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.tag.TagGroup import moe.apex.breadboard.util.MigrationOnlyField +import moe.apex.breadboard.util.PixivArtwork @Serializable @@ -21,7 +22,8 @@ data class ImageMetadata( val uncategorisedTags: List? = null, val groupedTags: List = emptyList(), val rating: ImageRating, - val pixivId: Int? = null, + @SerialName("pixivId") + private val pixivArtworkId: Int? = null, @MigrationOnlyField @SerialName("artist") @Deprecated( @@ -36,8 +38,18 @@ data class ImageMetadata( val tags: List get() = groupedTags.fold(emptyList()) { acc, tagGroup -> acc + tagGroup.tags } + val pixivId: Int? + get() = pixivArtworkId ?: pixivArtwork?.id + + val pixivArtwork: PixivArtwork? + get() = PixivArtwork.fromUrl(source) ?: pixivArtworkId?.let { PixivArtwork(it, 0) } + val pixivUrl: String? - get() = pixivId?.let { "https://www.pixiv.net/en/artworks/$it" } + get() = pixivArtwork?.let { + val id = if (it.index == 0) it.id.toString() + else "${it.id}#${it.index}" + "https://www.pixiv.net/artworks/$id" + } } @@ -61,9 +73,11 @@ data class Image( val hasGroupedTags: Boolean get() { - /* If the image does not have ID or metadata, we would have no way of fetching additional info. - Therefore, the image should just be treated as already having grouped tags. */ - if (id == null || metadata == null) return true + /* We use this to determine whether we should fetch updated (grouped) tags for a post. + On Gelbooru this required the tags themselves, which won't exist if metadata is null. + That adds a bit of complexity that we need to handle elsewhere, + but it keeps this property 'truthful' so to speak. */ + if (metadata == null) return false /* Usually, we can tell that the existing grouped tags are grouped properly if they have more than one group. diff --git a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt index 6439fb78..d844ba9b 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -9,7 +9,6 @@ import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.tag.TagGroup import moe.apex.breadboard.tag.TagSuggestion import moe.apex.breadboard.util.decodeHtml -import moe.apex.breadboard.util.extractPixivId import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -195,7 +194,6 @@ interface GelbooruBasedImageBoard : ImageBoard { } val metaRating = getRatingFromString(e.getString("rating")) - val metaPixivId = extractPixivId(metaSource) val metadata = ImageMetadata( parentId = metaParentId, hasChildren = null, // Not available for Gelbooru-based image boards @@ -203,7 +201,6 @@ interface GelbooruBasedImageBoard : ImageBoard { source = metaSource, groupedTags = metaGroupedTags, rating = metaRating, - pixivId = metaPixivId, ) return Image(id, fileName, fileFormat, previewUrl, fileUrl, sampleUrl, imageSource, aspectRatio, metadata) @@ -286,7 +283,8 @@ object Rule34 : GelbooruBasedImageBoard { } override suspend fun loadImageGroupedTags(image: Image, auth: ImageBoardAuth?): ImageMetadata? { - return image.id?.let { loadImage(it, auth)?.metadata } + val img = image.id?.let { loadImage(it, auth) } ?: loadImageMd5(image.fileName, auth) + return img?.metadata } } @@ -314,6 +312,9 @@ object Safebooru : GelbooruBasedImageBoard { } override suspend fun loadImageGroupedTags(image: Image, auth: ImageBoardAuth?): ImageMetadata? { + /* We can't use MD5 as a fallback here because on Safebooru, + the fileName (which Breadboard stores) is different from the MD5 hash, + and I haven't found a way to search using the filename. */ return image.id?.let { loadImage(it, auth)?.metadata } } } @@ -459,7 +460,7 @@ object Danbooru : ImageBoard { source = metaSource, groupedTags = metaGroupedTags, rating = metaRating, - pixivId = metaPixivId, + pixivArtworkId = metaPixivId, ) return Image(id, fileName, fileFormat, previewUrl, fileUrl, sampleUrl, ImageSource.DANBOORU, aspectRatio, metadata) @@ -491,9 +492,11 @@ object Danbooru : ImageBoard { } override suspend fun loadImageGroupedTags(image: Image, auth: ImageBoardAuth?): ImageMetadata? { - return image.id?.let { loadImage(it, auth)?.metadata } + val img = image.id?.let { loadImage(it, auth) } ?: loadImageMd5(image.fileName, auth) + return img?.metadata } + override fun getRatingFromString(rating: String): ImageRating { return when (rating) { "g" -> ImageRating.SAFE @@ -543,14 +546,12 @@ object Yandere : ImageBoard { TagCategory.GENERAL.group(e.getString("tags").decodeHtml().split(" ")), ) val metaRating = getRatingFromString(e.getString("rating")) - val metaPixivId = extractPixivId(metaSource) val metadata = ImageMetadata( parentId = metaParentId, hasChildren = metaHasChildren, source = metaSource, groupedTags = metaGroupedTags, rating = metaRating, - pixivId = metaPixivId, ) return Image(id, fileName, fileFormat, previewUrl, fileUrl, sampleUrl, ImageSource.YANDERE, aspectRatio, metadata) diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt index 09130e18..26cddca1 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.CheckCircleOutline import androidx.compose.material.icons.rounded.ContentCopy @@ -48,7 +49,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -69,6 +70,7 @@ import androidx.navigation.NavController import kotlinx.coroutines.launch import moe.apex.breadboard.DeepLinkActivity import moe.apex.breadboard.MainActivity +import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.image.Image import moe.apex.breadboard.navigation.ImageView import moe.apex.breadboard.navigation.Results @@ -111,9 +113,7 @@ private enum class InfoSheetPage { } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, - ExperimentalMaterial3ExpressiveApi::class -) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () -> Unit) { /* I don't really like this whole info/options implementation. @@ -130,7 +130,10 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - val scope = rememberCoroutineScope() val unified = prefs.unifiedInfoSheet - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = !unified) + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = if (unified) SheetValue.entries.toSet() else setOf(SheetValue.Expanded, SheetValue.Hidden), + ) var sheetPage by remember { mutableStateOf(InfoSheetPage.SOURCES) } fun hideAndThen(block: () -> Unit = { }) { @@ -172,7 +175,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - TitledModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, - title = "About this image", + title = "About this art", ) { if (selectedTag != null) { /* We need to have this dialog inside the sheet otherwise it'll just automatically @@ -231,18 +234,32 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - ) { if (blocked) { scope.launch { - preferencesRepository.removeFromSet( - PreferenceKeys.MANUALLY_BLOCKED_TAGS, - selectedTag!! - ) + if (selectedTag!! in AI_TAG_NAMES) { + preferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + false + ) + } else { + preferencesRepository.removeFromSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + selectedTag!! + ) + } } showToast(context, "Unblocked tag ${selectedTag!!}") } else { scope.launch { - preferencesRepository.addToSet( - PreferenceKeys.MANUALLY_BLOCKED_TAGS, - selectedTag!! - ) + if (selectedTag in AI_TAG_NAMES) { + preferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + true + ) + } else { + preferencesRepository.addToSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + selectedTag!! + ) + } } showToast(context, "Blocked tag ${selectedTag!!}") } @@ -436,7 +453,6 @@ private fun ImageboardDataTabContent( } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun UnifiedInfoContent( image: Image, @@ -492,7 +508,6 @@ private fun SplitInfoSheetLazyColumn(content: LazyListScope.() -> Unit) { } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LazyListScope.infoContentItems( image: Image, onLinkClick: (String) -> Unit, @@ -504,6 +519,12 @@ private fun LazyListScope.infoContentItems( onTagLongClick: (String) -> Unit, unified: Boolean = false ) { + if (image.isAiGenerated) { + item { + InfoSheetAiWarning() + } + } + item { Row { BasicExpressiveContainer( @@ -599,7 +620,6 @@ private fun LazyListScope.infoContentItems( @Suppress("unused") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LazyListScope.imageboardDataContentItems( image: Image, onLinkClick: (String) -> Unit, // Not currently used but keeping for consistency and possible future use @@ -779,7 +799,6 @@ private fun TagsContainer( } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun ExpandCollapseRow( label: String, @@ -816,3 +835,30 @@ private fun createSearchIntent(context: Context, imageSource: ImageSource, queri ) return intent } + + +private val Image.isAiGenerated: Boolean + get() = AI_TAG_NAMES.any { it in this.metadata?.tags.orEmpty() } + + +@Composable +private fun InfoSheetAiWarning() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = LARGE_SPACER.dp), + horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + tint = MaterialTheme.colorScheme.onSurfaceVariant, + imageVector = Icons.Outlined.Info, + contentDescription = null + ) + Text( + text = "This post is AI-generated.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt index 5e64b1c7..6d918b34 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt @@ -197,15 +197,15 @@ private enum class ToolbarState { @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable -fun LargeImageView( +private fun LargeImageView( navController: NavController, - initialPage: Int, + initialSelectedImageIndex: Int, allImages: List, onImageUpdate: (suspend (Image, Image) -> Unit)? = null, onZoomedStatusChanged: ((Boolean) -> Unit)? = null ) { val pagerState = rememberPagerState( - initialPage = initialPage, + initialPage = initialSelectedImageIndex, initialPageOffsetFraction = 0f ) { allImages.size } var canChangePage by remember { mutableStateOf(false) } @@ -788,7 +788,6 @@ fun LargeImage(image: Image) { } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun VideoMuteButton( muted: Boolean, @@ -822,7 +821,7 @@ private fun VideoMuteButton( } -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun VideoPlayPauseButton( isPlaying: Boolean, @@ -867,7 +866,7 @@ private fun VideoPlayPauseButton( } -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun LargeVideo(image: Image, isCurrentPage: Boolean, onLongClick: (() -> Unit)? = null) { val context = LocalContext.current @@ -1186,7 +1185,7 @@ fun LargeVideo(image: Image, isCurrentPage: Boolean, onLongClick: (() -> Unit)? fun OffsetBasedLargeImageView( navController: NavController, isActive: Boolean, - initialPage: Int, + initialSelectedImageIndex: Int, allImages: List, onActiveStateChanged: (Boolean) -> Unit = { }, onImageUpdate: (suspend (Image, Image) -> Unit)? = null, @@ -1312,7 +1311,7 @@ fun OffsetBasedLargeImageView( key(viewerSessionId) { LargeImageView( navController = navController, - initialPage = initialPage, + initialSelectedImageIndex = initialSelectedImageIndex, allImages = allImages, onImageUpdate = onImageUpdate, onZoomedStatusChanged = { canDragDown = !it } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt index 3b5f06f5..4df32be0 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt @@ -96,13 +96,19 @@ fun BlockedTagsScreen(navController: NavHostController) { .trim() .split(" ") .filter { it.isNotBlank() } - .filterNot { it in AI_TAG_NAMES } scope.launch { for (tag in newBlocks) { - userPreferencesRepository.addToSet( - PreferenceKeys.MANUALLY_BLOCKED_TAGS, - tag - ) + if (tag in AI_TAG_NAMES) { + userPreferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + true + ) + } else { + userPreferencesRepository.addToSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + tag + ) + } } } showAddDialog = false diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index c65ca444..ef0db442 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -47,10 +47,11 @@ import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.util.AgeVerification import moe.apex.breadboard.util.MigrationOnlyField +import moe.apex.breadboard.util.PixivArtwork import moe.apex.breadboard.util.SecretsManager import moe.apex.breadboard.util.availableRatingsForSource -import moe.apex.breadboard.util.extractPixivId import moe.apex.breadboard.util.decodeHtml +import moe.apex.breadboard.util.replaceGelbooruSubdomain import java.io.IOException @@ -92,7 +93,6 @@ data object PrefNames { const val UNFOLLOWED_TAGS = "unfollowed_tags" const val RECOMMENDATIONS_TAG_COUNT = "recommendations_tag_count" const val RECOMMENDATIONS_POOL_SIZE = "recommendations_pool_size" - const val RECOMMENDATIONS_WEIGHTED_SELECTION = "recommendations_weighted_selection" const val INTERNAL_IGNORE_LIST_TIMESTAMP = "internal_ignore_list_timestamp" const val INTERNAL_IGNORE_LIST = "internal_ignore_list" const val AUTOPLAY_VIDEOS = "autoplay_videos" @@ -126,7 +126,6 @@ object PreferenceKeys { val UNFOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.UNFOLLOWED_TAGS) val RECOMMENDATIONS_TAG_COUNT = intPreferencesKey(PrefNames.RECOMMENDATIONS_TAG_COUNT) val RECOMMENDATIONS_POOL_SIZE = intPreferencesKey(PrefNames.RECOMMENDATIONS_POOL_SIZE) - val RECOMMENDATIONS_WEIGHTED_SELECTION = booleanPreferencesKey(PrefNames.RECOMMENDATIONS_WEIGHTED_SELECTION) val INTERNAL_IGNORE_LIST_TIMESTAMP = longPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST_TIMESTAMP) val INTERNAL_IGNORE_LIST = stringSetPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST) val AUTOPLAY_VIDEOS = stringPreferencesKey(PrefNames.AUTOPLAY_VIDEOS) @@ -224,7 +223,6 @@ data class Prefs( val unfollowedTags: Set, val recommendationsTagCount: Int, val recommendationsPoolSize: Int, - val recommendationsWeightedSelection: Boolean, val internalIgnoreListTimestamp: Long, val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, @@ -257,7 +255,6 @@ data class Prefs( unfollowedTags = emptySet(), recommendationsTagCount = 3, recommendationsPoolSize = 7, - recommendationsWeightedSelection = true, internalIgnoreListTimestamp = 0, internalIgnoreList = emptySet(), autoplayVideos = AutoplayVideosMode.OFF, @@ -322,7 +319,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { PreferenceKeys.UNFOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), PreferenceKeys.RECOMMENDATIONS_TAG_COUNT to PrefMeta(PrefCategory.SETTING), PreferenceKeys.RECOMMENDATIONS_POOL_SIZE to PrefMeta(PrefCategory.SETTING), - PreferenceKeys.RECOMMENDATIONS_WEIGHTED_SELECTION to PrefMeta(PrefCategory.SETTING), PreferenceKeys.INTERNAL_IGNORE_LIST_TIMESTAMP to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.INTERNAL_IGNORE_LIST to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.UNIFIED_INFO_SHEET to PrefMeta(PrefCategory.SETTING) @@ -404,7 +400,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { uncategorisedTags = null, groupedTags = if (!image.metadata.uncategorisedTags.isNullOrEmpty()) listOf(TagCategory.GENERAL.group(image.metadata.tags)) else emptyList(), - pixivId = image.metadata.pixivId ?: extractPixivId(image.metadata.source) + pixivArtworkId = image.metadata.pixivId ?: PixivArtwork.fromUrl(image.metadata.source)?.id ) ) ) @@ -424,31 +420,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { updatePref(PreferenceKeys.FILTER_RATINGS_LOCALLY, false) } - /* Version code 270 enabled the staggered grid by default. Don't change it for people who - hadn't enabled it previously. - Additionally, fix Gelbooru favourite image links since their subdomain changed from img3 - to img4. */ - if (lastUsedVersionCode < 270) { - val data = dataStore.data.first() - val brokenFavouritesByteArray = data[PreferenceKeys.FAVOURITE_IMAGES] - - if (brokenFavouritesByteArray != null) { - val brokenFavourites: List = Cbor.decodeFromByteArray(brokenFavouritesByteArray) - val tempFavourites = brokenFavourites.toMutableList() - brokenFavourites.forEachIndexed { index, img -> - if (img.imageSource == ImageSource.GELBOORU) { - val fixedImage = img.copy( - previewUrl = img.previewUrl.replace("img3.gelbooru", "img4.gelbooru"), - fileUrl = img.fileUrl.replace("img3.gelbooru", "img4.gelbooru"), - sampleUrl = img.sampleUrl.replace("img3.gelbooru", "img4.gelbooru") - ) - tempFavourites[index] = fixedImage - } - } - updateFavouriteImages(tempFavourites) - } - } - // v3 (code 300) migrations start here /* Versions 270 and 271 had a bug where staggered might still be disabled by default for new @@ -548,6 +519,47 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } } + /* Version 3.2.2 removes the weighted tags pref in favour of a new recommendation system. */ + if (lastUsedVersionCode < 322) { + val data = dataStore.data.first() + val key = booleanPreferencesKey("recommendations_weighted_selection") + if (data.contains(key)) { + dataStore.edit { prefs -> + prefs.remove(key) + } + } + + val favouritesByteArray = data[PreferenceKeys.FAVOURITE_IMAGES] + + if (favouritesByteArray != null) { + val favouriteImages: List = Cbor.decodeFromByteArray(favouritesByteArray) + val tempFavourites = favouriteImages.toMutableList() + tempFavourites.forEachIndexed { index, img -> + if (img.imageSource == ImageSource.GELBOORU) { + val fixedImage = img.copy( + previewUrl = replaceGelbooruSubdomain(img.previewUrl), + fileUrl = replaceGelbooruSubdomain(img.fileUrl), + sampleUrl = replaceGelbooruSubdomain(img.sampleUrl) + ) + tempFavourites[index] = fixedImage + } + } + updateFavouriteImages(tempFavourites) + } + } + + /* Version 3.2.3 removes AI tags from the manual section in favour of handling them all + with the EXCLUDE_AI pref. */ + if (lastUsedVersionCode < 323) { + val data = dataStore.data.first() + val blockedTags = data[PreferenceKeys.MANUALLY_BLOCKED_TAGS] ?: emptySet() + val blockedTagsWithoutAi = blockedTags.filterNot { it in AI_TAG_NAMES } + if (blockedTags.size != blockedTagsWithoutAi.size) { + updateSet(PreferenceKeys.MANUALLY_BLOCKED_TAGS, blockedTagsWithoutAi) + updatePref(PreferenceKeys.EXCLUDE_AI, true) + } + } + /* Always clear the internal ignored list on updates. */ if (BuildConfig.VERSION_CODE != lastUsedVersionCode) { dataStore.edit { prefs -> @@ -803,7 +815,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val unfollowedTags = preferences[PreferenceKeys.UNFOLLOWED_TAGS] ?: Prefs.DEFAULT.unfollowedTags val recommendationsTagCount = preferences[PreferenceKeys.RECOMMENDATIONS_TAG_COUNT] ?: Prefs.DEFAULT.recommendationsTagCount val recommendationsPoolSize = preferences[PreferenceKeys.RECOMMENDATIONS_POOL_SIZE] ?: Prefs.DEFAULT.recommendationsPoolSize - val recommendationsWeightedSelection = preferences[PreferenceKeys.RECOMMENDATIONS_WEIGHTED_SELECTION] ?: Prefs.DEFAULT.recommendationsWeightedSelection val internalIgnoreListTimestamp = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST_TIMESTAMP] ?: Prefs.DEFAULT.internalIgnoreListTimestamp val internalIgnoreList = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST] ?: Prefs.DEFAULT.internalIgnoreList val autoplayVideos = preferences[PreferenceKeys.AUTOPLAY_VIDEOS]?.let { AutoplayVideosMode.valueOf(it) } ?: Prefs.DEFAULT.autoplayVideos @@ -835,7 +846,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { unfollowedTags, recommendationsTagCount, recommendationsPoolSize, - recommendationsWeightedSelection, internalIgnoreListTimestamp, internalIgnoreList, autoplayVideos, diff --git a/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt b/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt index 102b0526..60a47292 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt @@ -36,13 +36,14 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton +import androidx.compose.material3.SheetValue import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -104,7 +105,10 @@ fun InfoButton( text: String, ) { var showInfoSheet by rememberSaveable { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded), + ) if (showInfoSheet) { TitledModalBottomSheet( onDismissRequest = { showInfoSheet = false }, diff --git a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt index 8b319590..602b6169 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt @@ -82,7 +82,7 @@ private const val INFO_SECTION = -2 private const val PAGER_BUTTON_SIZE_DP = 64 -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun RecommendationsSettingsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() @@ -362,9 +362,9 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { Summary( modifier = Modifier.padding(horizontal = MEDIUM_LARGE_SPACER.dp), text = "Your frequent tags consist of the most common tags from your " + - "favourite images. Breadboard will use these tags to recommend " + - "new content. You can tap a tag above to ignore it, preventing it " + - "from being used to recommend new content." + "favourite images. Breadboard will intelligently use these tags to " + + "recommend new content. You can tap a tag above to ignore it, " + + "preventing it from being used to recommend new content." ) } @@ -439,22 +439,6 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { } } } - item { - SwitchPref( - checked = prefs.recommendationsWeightedSelection, - title = "Respect tag order", - summary = "Tags that appear earlier in your frequent tags list are " + - "more likely to be used when recommending new content." - ) { - scope.launch { - userPreferencesRepository.updatePref( - PreferenceKeys.RECOMMENDATIONS_WEIGHTED_SELECTION, - it - ) - } - resetRecommendations() - } - } } } diff --git a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt index a37e88ff..e84164b2 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -1,11 +1,11 @@ package moe.apex.breadboard.search -import android.annotation.SuppressLint import android.text.format.DateFormat import android.util.Log import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.EaseOutBack import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -21,12 +21,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -59,12 +56,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -84,10 +82,8 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -119,7 +115,6 @@ import moe.apex.breadboard.util.ListItemPosition import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.BOTTOM_APP_BAR_HEIGHT import moe.apex.breadboard.util.BaseHeading -import moe.apex.breadboard.util.DISABLED_OPACITY import moe.apex.breadboard.util.SearchHistoryListItem import moe.apex.breadboard.util.ExpressiveTagEntryContainer import moe.apex.breadboard.util.LARGE_SPACER @@ -148,7 +143,6 @@ const val ANIMATION_DURATION_MS = 300 @OptIn(ExperimentalMaterial3Api::class) -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "ConfigurationScreenWidthHeight") @Composable fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { /* We use shouldShowSuggestions for determining autocomplete section visibility because if we @@ -174,9 +168,6 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { val currentSource = prefs.imageSource var showSearchHistoryPopup by rememberSaveable { mutableStateOf(false) } - val historySheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val is24h = DateFormat.is24HourFormat(context) - val timeFormat = if (is24h) "HH:mm" else "h:mm a" var searchJob: Job? = null val scope = rememberCoroutineScope() @@ -267,100 +258,6 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { ) } - - @Composable - fun TagListEntry( - modifier: Modifier = Modifier, - tag: TagSuggestion, - index: Int - ) { - ExpressiveTagEntryContainer( - modifier = modifier, - label = tag.label, - supportingLabel = tag.category, - trailingContent = { - // Show hint that the user can press enter to add the first tag in the list - AnimatedVisibility( - visible = index == 0, - enter = fadeIn(), - exit = fadeOut(tween(durationMillis = 150)) // Long enough to feel smooth, short enough to not linger if it goes from first to non-first when the list updates - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardReturn, - contentDescription = "Press enter to add", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = SMALL_SPACER.dp) - ) - } - }, - position = when (index) { - 0 -> if (mostRecentSuggestions.size == 1) ListItemPosition.SINGLE_ELEMENT else ListItemPosition.TOP - mostRecentSuggestions.lastIndex -> ListItemPosition.BOTTOM - else -> ListItemPosition.MIDDLE - } - ) { - searchString = "" - cleanedSearchString = "" - shouldShowSuggestions = false - addToFilter(tag) - } - } - - - @Composable - fun AutoCompleteTagResults() { - Column( - modifier = Modifier - .consumeWindowInsets(PaddingValues(0.dp, 0.dp, 0.dp, (BOTTOM_APP_BAR_HEIGHT + 16).dp)) - .imePadding() - ) { - Box( - modifier = Modifier - .padding( - start = SMALL_LARGE_SPACER.dp, - end = SMALL_LARGE_SPACER.dp, - bottom = SMALL_LARGE_SPACER.dp - ) - .clip(largerShape) - ) { - val resultsState = rememberLazyListState() - LaunchedEffect(mostRecentSuggestions) { - scope.launch { - resultsState.animateScrollToItem(0) - } - } - LazyColumn( - state = resultsState, - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - if (mostRecentSuggestions.isEmpty()) { - item { - Text( - fontSize = 16.sp, - text = "No results :(", - textAlign = TextAlign.Center, - modifier = Modifier - .padding(SMALL_LARGE_SPACER.dp) - .fillMaxWidth(), - ) - } - } else { - mostRecentSuggestions.forEachIndexed { index, t -> - item(key = t.label) { - TagListEntry( - modifier = Modifier.animateItem(), - tag = t, - index = index - ) - } - } - } - } - } - } - } - fun performSearch() { if (tagChipList.isEmpty()) return showToast(context, "Please select some tags") @@ -768,7 +665,12 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { enter = fadeIn(tween(durationMillis = 300)), exit = fadeOut(tween(durationMillis = 300)) ) { - AutoCompleteTagResults() + AutoCompleteTagResults(mostRecentSuggestions) { + searchString = "" + cleanedSearchString = "" + shouldShowSuggestions = false + addToFilter(it) + } } } } @@ -804,84 +706,186 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { } if (showSearchHistoryPopup) { - val locale = LocalLocale.current.platformLocale - val density = LocalDensity.current - val reversedSearchHistory = remember(prefs.searchHistory) { prefs.searchHistory.reversed() } - var contentHeight by remember { mutableStateOf(Float.MAX_VALUE.dp) } - val containerHeight by animateDpAsState(contentHeight) - val navBarHeight = navBarHeight // We need to calculate this ahead of time - - /* I'd like to use animateContentSize on the LazyColumn but doing so can cause some - strange animation bugs when opening the sheet. - The workaround using a container controlled by onSizeChanged isn't great - but it's close enough to what we want. - - We start with a large initial height and then calculate the correct (smaller) - value because reducing the height doesn't cause the strange opening behaviour - whereas increasing the height apparently does. - - ModalBottomSheet forcibly adds IME padding that causes the LazyColumn to - report a smaller height than desired if the IME is visible when the sheet opens. - This could be useful if we had a text field in the sheet but we don't so the IME - just gets dismissed and the padding becomes an annoyance. - To work around this we'll add the IME height to the LazyColumn's calculated height. - This allows it to animate to the proper height once the IME is finished dismissing. - - ModalBottomSheets are just kind of bad in general. - They're janky to use and the API surface is annoying. */ - - TitledModalBottomSheet( + SearchHistorySheet( + isIncognito = incognito, onDismissRequest = { showSearchHistoryPopup = false }, - sheetState = historySheetState, - title = "Search history" ) { - val imeSize = WindowInsets.ime.asPaddingValues().calculateBottomPadding() // We don't want to calculate this ahead of time + viewModel.setTagSuggestions(it.tags.toList()) + searchString = "" + shouldShowSuggestions = false + scope.launch { + context.prefs.updatePref( + PreferenceKeys.IMAGE_SOURCE, + it.source + ) + context.prefs.replaceImageRatings(it.ratings) + } + } + } +} - Box(modifier = Modifier.height(containerHeight)) { - LazyColumn( - modifier = Modifier - .padding(horizontal = MEDIUM_SPACER.dp) - .clip(largerShape) - .onSizeChanged { - contentHeight = with (density) { it.height.toDp() } + imeSize - }, - verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp, Alignment.Top), - contentPadding = PaddingValues(bottom = navBarHeight + MEDIUM_SPACER.dp) - ) { - if (prefs.searchHistory.isEmpty()) { - SearchHistoryStandaloneTextItem("No search history yet. Start searching!") - } else { - if (incognito) { - SearchHistoryStandaloneTextItem("Incognito mode is enabled. Search history will not be saved.") + +@Composable +fun AutoCompleteTagResults( + mostRecentSuggestions: List, + onTagClick: (TagSuggestion) -> Unit +) { + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .consumeWindowInsets(PaddingValues(0.dp, 0.dp, 0.dp, (BOTTOM_APP_BAR_HEIGHT + 16).dp)) + .imePadding() + ) { + Box( + modifier = Modifier + .padding( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + bottom = SMALL_LARGE_SPACER.dp + ) + .clip(largerShape) + ) { + val resultsState = rememberLazyListState() + + LaunchedEffect(mostRecentSuggestions) { + scope.launch { + resultsState.animateScrollToItem(0) + } + } + + LazyColumn( + state = resultsState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (mostRecentSuggestions.isEmpty()) { + item { + Text( + fontSize = 16.sp, + text = "No results :(", + textAlign = TextAlign.Center, + modifier = Modifier + .padding(SMALL_LARGE_SPACER.dp) + .fillMaxWidth(), + ) + } + } else { + mostRecentSuggestions.forEachIndexed { index, t -> + item(key = t.label) { + TagListEntry( + modifier = Modifier.animateItem(), + tag = t, + mostRecentSuggestions = mostRecentSuggestions, + index = index, + onClick = onTagClick + ) } - items(reversedSearchHistory, key = { it.timestamp }) { entry -> - val date = Date(entry.timestamp) - val formatter = - SimpleDateFormat("dd MMM $timeFormat", locale) - val formattedDate = formatter.format(date) - - Column( - modifier = Modifier.animateItem(placementSpec = bouncyAnimationSpec()), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - BaseHeading( - modifier = Modifier.padding(start = SMALL_SPACER.dp), - text = "$formattedDate \u2022 ${entry.source.label}" - ) - SearchHistoryListItem(entry) { - viewModel.setTagSuggestions(entry.tags.toList()) - searchString = "" - shouldShowSuggestions = false - scope.launch { - context.prefs.updatePref( - PreferenceKeys.IMAGE_SOURCE, - entry.source - ) - context.prefs.replaceImageRatings(entry.ratings) - historySheetState.hide() - showSearchHistoryPopup = false - } - } + } + } + } + } + } +} + + +@Composable +fun TagListEntry( + modifier: Modifier = Modifier, + tag: TagSuggestion, + mostRecentSuggestions: List, + index: Int, + onClick: (TagSuggestion) -> Unit +) { + ExpressiveTagEntryContainer( + modifier = modifier, + label = tag.label, + supportingLabel = tag.category, + trailingContent = { + // Show hint that the user can press enter to add the first tag in the list + AnimatedVisibility( + visible = index == 0, + enter = fadeIn(), + exit = fadeOut(tween(durationMillis = 150)) // Long enough to feel smooth, short enough to not linger if it goes from first to non-first when the list updates + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardReturn, + contentDescription = "Press enter to add", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = SMALL_SPACER.dp) + ) + } + }, + position = when (index) { + 0 -> if (mostRecentSuggestions.size == 1) ListItemPosition.SINGLE_ELEMENT else ListItemPosition.TOP + mostRecentSuggestions.lastIndex -> ListItemPosition.BOTTOM + else -> ListItemPosition.MIDDLE + }, + onClick = { onClick(tag) } + ) +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchHistorySheet( + isIncognito: Boolean, + onDismissRequest: () -> Unit, + onSearchHistoryEntryClick: (SearchHistoryEntry) -> Unit +) { + val locale = LocalLocale.current.platformLocale + val context = LocalContext.current + val prefs = LocalPreferences.current + val scope = rememberCoroutineScope() + + val reversedSearchHistory = remember(prefs.searchHistory) { prefs.searchHistory.reversed() } + val navBarHeight = navBarHeight // We need to calculate this ahead of time + + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded) + ) + val is24h = DateFormat.is24HourFormat(context) + val timeFormat = if (is24h) "HH:mm" else "h:mm a" + + TitledModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + title = "Search history" + ) { + LazyColumn( + modifier = Modifier + .padding(horizontal = MEDIUM_SPACER.dp) + .clip(largerShape) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp, Alignment.Top), + contentPadding = PaddingValues(bottom = navBarHeight + MEDIUM_SPACER.dp) + ) { + if (prefs.searchHistory.isEmpty()) { + SearchHistoryStandaloneTextItem("No search history yet. Start searching!") + } else { + if (isIncognito) { + SearchHistoryStandaloneTextItem("Incognito mode is enabled. Search history will not be saved.") + } + + items(reversedSearchHistory, key = { it.timestamp }) { entry -> + val date = Date(entry.timestamp) + val formatter = + SimpleDateFormat("dd MMM $timeFormat", locale) + val formattedDate = formatter.format(date) + + Column( + modifier = Modifier.animateItem(placementSpec = bouncyAnimationSpec()), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + BaseHeading( + modifier = Modifier.padding(start = SMALL_SPACER.dp), + text = "$formattedDate \u2022 ${entry.source.label}" + ) + SearchHistoryListItem(entry) { + onSearchHistoryEntryClick(entry) + scope.launch { sheetState.hide() }.invokeOnCompletion { + onDismissRequest() } } } @@ -898,11 +902,11 @@ private fun LazyListScope.SearchHistoryStandaloneTextItem(text: String) { Text( text = text, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(SMALL_LARGE_SPACER.dp) - .alpha(DISABLED_OPACITY) ) } } diff --git a/app/src/main/java/moe/apex/breadboard/util/Links.kt b/app/src/main/java/moe/apex/breadboard/util/Links.kt index 8c5a6d4e..3222dc2f 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Links.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Links.kt @@ -16,13 +16,35 @@ private val links = mapOf( fun fixLink(link: String): String { val uri = link.toUri() for ((originalHost, fixedHost) in links) { - if (uri.host == originalHost) { - return uri.buildUpon().authority(fixedHost).build().toString() + val newHost = if (uri.host == originalHost) { + fixedHost } else if (uri.host!!.endsWith(".$originalHost")) { val subdomain = uri.host!!.substringBeforeLast(".$originalHost") - val newHost = "$subdomain.$fixedHost" - return uri.buildUpon().authority(newHost).build().toString() + "$subdomain.$fixedHost" + } else { + continue } + + var newPath: String? = null + var newFragment: String? = null + + /* The phixiv fixer cannot take the official pixiv image index syntax into account since + URI fragments are client-sided, so we have to use its own path syntax for indexed images. */ + if (fixedHost == "phixiv.net" && "/artworks/\\d+$".toRegex().containsMatchIn(uri.path ?: "")) { + val index = uri.fragment?.toIntOrNull().takeIf { it != 0 } + if (index != null) { + newPath = "${uri.path}/${index + 1}" + newFragment = "" + } + } + + return uri + .buildUpon() + .authority(newHost) + .path(newPath ?: uri.path) + .fragment(newFragment ?: uri.fragment) + .build() + .toString() } return link } diff --git a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt index ee9f6861..fd57a33b 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt @@ -4,34 +4,40 @@ package moe.apex.breadboard.util private val PIXIV_CURRENT_RX = // https://i.pximg.net/img-original/img/2022/11/27/21/27/08/103150283_p0.jpg (Safebooru #6517847) // https://i.pximg.net/img-master/img/2019/07/09/08/27/59/75629295_p0_master1200.jpg (Safebooru #3567627) - """https?://i\.pximg\.net/img-(?:original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(\d+)_p\d+(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex() + // https://i.pximg.net/img-original/img/2026/05/13/20/30/05/144732134-2ec1cc9314f12cef1751801f82cc21a0_p0.jpg (Safebooru #6757725) + """https?://i\.pximg\.net/img-(original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(?\d+)(-[0-9a-f]+)?_p(?\d+)(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex() // 2012-2016 pixiv direct image URLs private val PIXIV_2012_TO_2016_RX = listOf( // https://i1.pixiv.net/img-original/img/2016/10/02/16/47/39/59270556_p0.jpg (Safebooru #1843535) // No source, but I'd assume an `img-master` version exists too on this old subdomain - """https?://i\d+\.pixiv\.net/img-(?:original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(\d+)_p\d+(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex(), + """https?://i\d+\.pixiv\.net/img-(original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(?\d+)_p(?\d+)(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex(), // https://i1.pixiv.net/img47/img/l3lc201/34464791.png (Safebooru #1000441) // https://i1.pixiv.net/img21/img/togainuakira/34478247_big_p8.jpg (Safebooru #1000649) - """https?://i\d+\.pixiv\.net/img\d+/img/.+/(\d+)(_big_p\d+)?\.(png|jpg|jpeg|gif)""".toRegex() + """https?://i\d+\.pixiv\.net/img\d+/img/.+/(?\d+)(_big_p(?\d+))?\.(png|jpg|jpeg|gif)""".toRegex() ) // Pre-2012 pixiv direct image URLs private val PIXIV_PRE_2012_RX = // https://img13.pixiv.net/img/tubasarei/4894590.jpg (Safebooru #166629) - """https?://img\d+\.pixiv\.net/img/.+/(\d+)\.(png|jpg|jpeg|gif)""".toRegex() + """https?://img\d+\.pixiv\.net/img/.+/(?\d+)\.(png|jpg|jpeg|gif)""".toRegex() private val PIXIV_RX = listOf(PIXIV_CURRENT_RX) + PIXIV_2012_TO_2016_RX + PIXIV_PRE_2012_RX -fun extractPixivId(url: String?): Int? { - if (url == null) return null +data class PixivArtwork(val id: Int, val index: Int) { + companion object { + fun fromUrl(url: String?): PixivArtwork? { + if (url == null) return null - for (regex in PIXIV_RX) { - val match = regex.find(url) - val id = match?.groupValues?.get(1)?.toInt()?.takeIf { it != 0 } - if (id != null) return id - } + for (regex in PIXIV_RX) { + val match = regex.find(url) ?: continue + val id = match.groups["id"]?.value?.toIntOrNull().takeIf { it != 0 } ?: continue + val index = match.groups["index"]?.value?.toIntOrNull() ?: 0 + return PixivArtwork(id, index) + } - return null + return null + } + } } diff --git a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt index 75946bfa..a2168df8 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt @@ -15,7 +15,6 @@ import moe.apex.breadboard.image.ImageRating import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.viewmodel.GridStateHolderDelegate import moe.apex.breadboard.viewmodel.GridStateHolder -import kotlin.random.Random class RecommendationsProvider( @@ -28,7 +27,6 @@ class RecommendationsProvider( private val initialUnfollowedTags: Set, private val selectionSize: Int, private val poolSize: Int, - private val useWeightedSelection: Boolean, ) : GridStateHolder by GridStateHolderDelegate() { companion object { private const val SELECTION_SIZE_DANBOORU = 2 @@ -68,52 +66,23 @@ class RecommendationsProvider( shouldKeepSearching = true pageNumber = imageSource.imageBoard.firstPageIndex - val tagsFromFavourites = RecommendationsHelper.getAllTags( - images = seedImages.filter { it.imageSource == imageSource }, - allowAllRatings = showAllRatings, - excludedTags = blockedTags - ) + val filteredSeedImages = seedImages.filter { it.imageSource == imageSource } + .filter { showAllRatings || it.metadata?.rating == ImageRating.SAFE } - if (tagsFromFavourites.isEmpty()) { + if (filteredSeedImages.isEmpty()) { return } - val topTags = RecommendationsHelper.getMostCommonTags( - allTags = tagsFromFavourites, - followedTagsLimit = poolSize, + val finalSelectionSize = if (imageSource == ImageSource.DANBOORU && auth == null) SELECTION_SIZE_DANBOORU else selectionSize + + val selected = RecommendationsHelper.getRecommendedTags( + images = filteredSeedImages, + selectionSize = finalSelectionSize, + poolSize = poolSize, + hiddenTags = blockedTags, unfollowedTags = unfollowedTags ) - val finalSelectionSize = if (imageSource == ImageSource.DANBOORU && auth == null) SELECTION_SIZE_DANBOORU else selectionSize - val selected = if (topTags.size <= finalSelectionSize) { - topTags.map { it.first } - } else if (useWeightedSelection) { - val weightedTags = topTags.toMutableList() - var totalWeight = weightedTags.sumOf { it.second } - val result = mutableSetOf() - - while (result.size < finalSelectionSize && weightedTags.isNotEmpty() && totalWeight > 0) { - var randomNumber = Random.nextInt(totalWeight) - - var chosenTag: Pair? = null - for (tag in weightedTags) { - if (randomNumber < tag.second) { - chosenTag = tag - break - } - randomNumber -= tag.second - } - - chosenTag?.let { - result.add(it.first) - totalWeight -= it.second - weightedTags.remove(it) - } - } - result.toList() - } else { - topTags.map { it.first }.shuffled().take(finalSelectionSize) - } recommendedTags.addAll(selected) } @@ -138,7 +107,7 @@ class RecommendationsProvider( ) imageSource.imageBoard.formatTagNameString(recommendedTags) } else { - "${imageSource.imageBoard.formatTagNameString(recommendedTags)}+${ + "${imageSource.imageBoard.formatTagNameString(recommendedTags)} ${ ImageRating.buildSearchStringFor( if (showAllRatings) { ImageRating.entries.filter { it != ImageRating.UNKNOWN } diff --git a/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt b/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt index 25bd36ab..87cf5026 100644 --- a/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt +++ b/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt @@ -2,6 +2,8 @@ package moe.apex.breadboard.util import moe.apex.breadboard.image.Image import moe.apex.breadboard.image.ImageRating +import moe.apex.breadboard.tag.TagCategory +import kotlin.random.Random.Default.nextInt import kotlin.text.lowercase @@ -68,4 +70,135 @@ object RecommendationsHelper { return wantedTags } } + + /** + * Recommends a set of tags based on the user's favorites, ensuring that the selected + * tags co-occur in the user's favorites to provide relevant results. + */ + fun getRecommendedTags( + images: List, + selectionSize: Int, + poolSize: Int, + hiddenTags: Set = emptySet(), + unfollowedTags: Set = emptySet(), + ): List { + if (images.isEmpty()) return emptyList() + + /* Get all tags and their frequencies. + Ideally we'd just be able to use groupedTags, but older favourites don't have these, + and some sources don't have support for grouped tags at all (yande.re) in Breadboard. */ + val allTags = images.flatMap { it.metadata?.tags ?: emptyList() } + .map { it.lowercase() } + .filter { it !in hiddenTags && it !in unfollowedTags } + + if (allTags.isEmpty()) return emptyList() + + val tagFrequencies = allTags.groupingBy { it }.eachCount() + + /* Get the most specific category for each tag. + Older favourites might not have categorised tags (therefore default to GENERAL), + so if the same tag exists in more than one image and the categories are different, + prefer the more specific one. */ + val tagCategories = mutableMapOf() + images.forEach { image -> + image.metadata?.groupedTags?.forEach { group -> + group.tags.forEach { tag -> + val lowerTag = tag.lowercase() + val currentCategory = tagCategories[lowerTag] + // If we find a non-GENERAL category, use that instead. + if (currentCategory == null || currentCategory == TagCategory.GENERAL) { + tagCategories[lowerTag] = group.category + } + } + } + } + + // These are the tags that could actually be chosen + val pool = tagFrequencies.entries + .sortedByDescending { it.value } + .take(poolSize) + .map { it.key } + + if (pool.isEmpty()) return emptyList() + + val result = mutableListOf() + val remainingPool = pool.toMutableList() + + /* Of the tags in the pool, choose one to use as the primary. + The other chosen tags will be based on this one. */ + val primaryTag = pickWeightedTag(remainingPool, tagFrequencies, tagCategories) ?: return emptyList() + result.add(primaryTag) + remainingPool.remove(primaryTag) + + // Pick other tags that have appeared together with the primary one. + while (result.size < selectionSize && remainingPool.isNotEmpty()) { + val coExistenceCounts = mutableMapOf() + + // Count how many times each tag in the remaining pool co-exists with the other chosen tags. + remainingPool.forEach { candidate -> + var count = 0 + images.forEach { image -> + val imageTags = image.metadata?.tags?.map { it.lowercase() } ?: emptyList() + if (imageTags.contains(candidate) && imageTags.containsAll(result)) { + count++ + } + } + if (count > 0) { + coExistenceCounts[candidate] = count + } + } + + if (coExistenceCounts.isEmpty()) break + + val nextTag = pickWeightedTag(coExistenceCounts.keys.toList(), coExistenceCounts, tagCategories) + if (nextTag != null) { + result.add(nextTag) + remainingPool.remove(nextTag) + } else { + break + } + } + + return result + } + + + private fun pickWeightedTag( + candidates: List, + frequencies: Map, + categories: Map + ): String? { + if (candidates.isEmpty()) { + return null + } + + val weights = candidates.map { tag -> + val freq = frequencies[tag] ?: 1 + val category = categories[tag] ?: TagCategory.GENERAL + val categoryWeight = when (category) { + // TODO: Consider making these configurable in future? + TagCategory.ARTIST -> 1.5 + TagCategory.CHARACTER -> 2.0 + TagCategory.COPYRIGHT -> 3.5 + TagCategory.META -> 0.0 // We'll just ignore meta tags entirely for now. + TagCategory.GENERAL -> 1.0 + } + (freq * categoryWeight * 10).toInt() + } + + val totalWeight = weights.sum() + if (totalWeight == 0) { // This seems exceptionally unlikely but not impossible. + return candidates.random() + } + + var random = nextInt(totalWeight) + for (i in candidates.indices) { + random -= weights[i] + if (random < 0) { + return candidates[i] + } + } + + return candidates.last() + } } \ No newline at end of file diff --git a/app/src/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index 30d2cc18..afd67a32 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -77,12 +77,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SelectableChipColors import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -92,7 +95,7 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -220,8 +223,8 @@ private fun NavigationIcon(navController: NavController? = null) { } -@Composable @OptIn(ExperimentalMaterial3Api::class) +@Composable fun LargeTitleBar( title: String, scrollBehavior: TopAppBarScrollBehavior?, @@ -235,7 +238,8 @@ fun LargeTitleBar( navigationIcon = { NavigationIcon(navController) }, colors = TopAppBarDefaults.topAppBarColors().copy( scrolledContainerColor = BreadboardTheme.colors.titleBar - ) + ), + windowInsets = TopAppBarDefaults.windowInsets.only(WindowInsetsSides.Vertical) ) } @@ -255,7 +259,8 @@ fun SmallTitleBar( colors = TopAppBarDefaults.topAppBarColors( scrolledContainerColor = BreadboardTheme.colors.titleBar ), - navigationIcon = { NavigationIcon(navController) } + navigationIcon = { NavigationIcon(navController) }, + windowInsets = TopAppBarDefaults.windowInsets.only(WindowInsetsSides.Vertical) ) } @@ -322,7 +327,6 @@ fun MainScreenScaffold( fine grained control over its behaviour and appearance. [blur] is not supported on Android 11 or below.*/ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreenScaffold( topAppBar: @Composable () -> Unit, @@ -356,7 +360,8 @@ fun MainScreenScaffold( it() } } - } + }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.only(WindowInsetsSides.Vertical) ) { val lld = LocalLayoutDirection.current val newPadding = PaddingValues( @@ -810,11 +815,18 @@ fun SearchHistoryListItem( fun TitledModalBottomSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden), contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets.only(WindowInsetsSides.Horizontal) }, title: String, content: @Composable ColumnScope.() -> Unit ) { + LaunchedEffect(Unit) { + if (sheetState.hasPartiallyExpandedState) { + sheetState.partialExpand() + } else { + sheetState.expand() + } + } ModalBottomSheet( onDismissRequest = onDismissRequest, modifier = modifier.windowInsetsPadding(WindowInsets.statusBars), @@ -1182,7 +1194,6 @@ fun CombinedClickableAction( } -@OptIn(ExperimentalMaterial3Api::class) data class PullToRefreshController( val state: PullToRefreshState, val indicator: @Composable BoxScope.(PullToRefreshController) -> Unit, @@ -1222,7 +1233,6 @@ data class PullToRefreshController( } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun rememberPullToRefreshController( state: PullToRefreshState = rememberPullToRefreshState(), @@ -1245,18 +1255,16 @@ fun rememberPullToRefreshController( object PullToRefreshControllerDefaults { - @OptIn(ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun Indicator( modifier: Modifier = Modifier, controller: PullToRefreshController, ) { - PullToRefreshDefaults.Indicator( + PullToRefreshDefaults.LoadingIndicator( state = controller.state, isRefreshing = controller.isRefreshing, - modifier = modifier, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - color = MaterialTheme.colorScheme.onTertiaryContainer + modifier = modifier ) } } @@ -1268,7 +1276,6 @@ object PullToRefreshControllerDefaults { to work around what I can only assume is a Compose bug whereby the scrolling animation is jumpy when there is a full width item in the grid (like the filter). */ @SuppressLint("FrequentlyChangingValue") -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ScrollToTopArrow( staggeredGridState: LazyStaggeredGridState, @@ -1442,6 +1449,16 @@ fun rememberIsBlurEnabled(): Boolean { } +@Composable +fun WideLinearWavyProgressIndicator(modifier: Modifier = Modifier) { + LinearWavyProgressIndicator( + modifier = modifier, + amplitude = 0.6f, + wavelength = 40.dp, + ) +} + + val bottomAppBarAndNavBarHeight: Dp @Composable get() = BOTTOM_APP_BAR_HEIGHT.dp + navBarHeight diff --git a/app/src/main/java/moe/apex/breadboard/util/Uri.kt b/app/src/main/java/moe/apex/breadboard/util/Uri.kt index a40a9791..c670ae43 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Uri.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Uri.kt @@ -33,3 +33,12 @@ fun launchInWebBrowser(context: Context, uri: Uri) { launchUriWithPackage(context, uri, defaultPackage) } + + +fun replaceGelbooruSubdomain(url: String): String { + val uri = url.toUri() + return uri.buildUpon() + .authority("img4.gelbooru.com") + .build() + .toString() +} diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt index fffcc2da..d24ea2e4 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt @@ -1,82 +1,148 @@ package moe.apex.breadboard.viewmodel import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import moe.apex.breadboard.image.Image import moe.apex.breadboard.image.ImageBoardAuth +import moe.apex.breadboard.image.AI_TAG_NAMES +import moe.apex.breadboard.image.ImageRating import moe.apex.breadboard.preferences.ImageSource class SearchResultsViewModel : ViewModel(), GridStateHolder by GridStateHolderDelegate() { - private var isReadyInternal by mutableStateOf(false) - var doneInitialLoad by mutableStateOf(false) - var auth: ImageBoardAuth? = null - val images = mutableStateListOf() - private var shouldKeepSearching by mutableStateOf(true) - private var pageNumber by mutableIntStateOf(0) // We'll set this to the proper value later + private val _isReady = MutableStateFlow(false) + val isReady = _isReady.asStateFlow() + + private val _doneInitialLoad = MutableStateFlow(false) + val doneInitialLoad = _doneInitialLoad.asStateFlow() + + private val _auth = MutableStateFlow(null) + val auth = _auth.asStateFlow() + + private val _images = MutableStateFlow>(emptyList()) + val images = _images.asStateFlow() + + private val _shouldKeepSearching = MutableStateFlow(true) + + private val _pageNumber = MutableStateFlow(0) + + private val _blockedTags = MutableStateFlow>(emptySet()) + val blockedTags = _blockedTags.asStateFlow() + + private val _selectedRatings = MutableStateFlow>(emptySet()) + val selectedRatings = _selectedRatings.asStateFlow() + private lateinit var imageSource: ImageSource private lateinit var query: String - var isReady: Boolean = isReadyInternal - private set + private var tagList: List = emptyList() - fun setup(imageSource: ImageSource, auth: ImageBoardAuth?, tags: List) { - if (isReady) { - return - } - val authProvided = auth != null - Log.i("SearchResults", "Setting up SearchResultsViewModel with source: ${imageSource.name}, authenticated: $authProvided, tags: $tags") + fun setup( + imageSource: ImageSource, + auth: ImageBoardAuth?, + tags: List + ) { + Log.i("SearchResults", "Setting up SearchResultsViewModel with source: ${imageSource.name}, tags: $tags") this.imageSource = imageSource - this.auth = auth + tagList = tags query = imageSource.imageBoard.formatTagNameString(tags) - pageNumber = imageSource.imageBoard.firstPageIndex + _auth.value = auth + _pageNumber.value = imageSource.imageBoard.firstPageIndex resetGridStates() - isReady = true + _isReady.value = true } fun prepareReset() { Log.i("SearchResults", "Resetting SearchResultsViewModel") - isReady = false + _isReady.value = false + } + + + fun updateAuth(auth: ImageBoardAuth?) { + _auth.value = auth + } + + + fun updateBlockedTags(manuallyBlockedTags: Set, blockAi: Boolean) { + val blockList = if (AI_TAG_NAMES.any { it in tagList }) { + // Even if blockAi is true, we'll leave them unblocked if the user explicitly searched for AI + manuallyBlockedTags + } else if (blockAi) { + manuallyBlockedTags + AI_TAG_NAMES + } else { + manuallyBlockedTags + } + _blockedTags.value = blockList.filter { it !in tagList }.toSet() } suspend fun loadMore() { - if (!shouldKeepSearching) { + if (!_shouldKeepSearching.value) { Log.i("SearchResults", "No more images to load, stopping search.") return } - if (!isReady) { + if (!_isReady.value) { throw IllegalStateException("SearchResultsViewModel is not ready. Call setup() first.") } + try { - Log.i("SearchResults", "Loading more images for query: $query, page: $pageNumber") - val newImages = imageSource.imageBoard.loadPage(query, pageNumber, auth) + Log.i("SearchResults", "Loading more images for query: $query, page: ${_pageNumber.value}") + val newImages = imageSource.imageBoard.loadPage(query, _pageNumber.value, auth.value) if (newImages.isEmpty()) { - shouldKeepSearching = false + _shouldKeepSearching.value = false } else { - if (pageNumber == imageSource.imageBoard.firstPageIndex) { - Snapshot.withMutableSnapshot { - images.clear() - images.addAll(newImages) - } + if (_pageNumber.value == imageSource.imageBoard.firstPageIndex) { + _images.value = newImages } else { - images += newImages.filter { it !in images } + _images.value += newImages.filter { it !in _images.value } } - pageNumber++ + _pageNumber.value++ } } catch (e: Exception) { Log.e("SearchResults", "Error loading more images", e) - shouldKeepSearching = false + _shouldKeepSearching.value = false + } + if (!_doneInitialLoad.value) { + _doneInitialLoad.value = true } - if (!doneInitialLoad) { - doneInitialLoad = true + } + + + fun updateImage(oldImage: Image, newImage: Image) { + val index = _images.value.indexOf(oldImage) + if (index != -1) { + val updatedImages = _images.value.toMutableList().apply { this[index] = newImage } + _images.value = updatedImages + } + } + + + fun addRating(rating: ImageRating) { + _selectedRatings.value += rating + } + + + fun removeRating(rating: ImageRating) { + _selectedRatings.value -= rating + } + + + fun updateSelectedRatings(ratings: Set) { + _selectedRatings.value = ratings + } + + + fun filterImages(ratings: Set? = null): List { + return _images.value.filter { image -> + val isNotBlocked = image.metadata!!.tags.none { tag -> + _blockedTags.value.contains(tag.lowercase()) + } + val passesRatingFilter = ratings?.contains(image.metadata.rating) ?: true + + isNotBlocked && passesRatingFilter } } } \ No newline at end of file diff --git a/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt b/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt new file mode 100644 index 00000000..c358f942 --- /dev/null +++ b/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt @@ -0,0 +1,66 @@ +package moe.apex.breadboard.util + +import moe.apex.breadboard.image.Image +import moe.apex.breadboard.image.ImageMetadata +import moe.apex.breadboard.image.ImageRating +import moe.apex.breadboard.tag.TagCategory +import moe.apex.breadboard.tag.TagGroup +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecommendationsHelperTest { + private fun createImage(tags: List, category: TagCategory = TagCategory.GENERAL): Image { + return Image( + fileName = "test", + fileFormat = "jpg", + previewUrl = "", + fileUrl = "", + sampleUrl = "", + metadata = ImageMetadata( + rating = ImageRating.SAFE, + groupedTags = listOf(TagGroup(category, tags)) + ) + ) + } + + + @Test + fun testCoOccurrenceSelection() { + // Two clusters of unrelated tags + // Cluster 1: blue_archive, hoshino_(blue_archive), halo + // Cluster 2: arknights, silverash_(arknights), sword + val images = listOf( + createImage(listOf("blue_archive", "hoshino_(blue_archive)", "halo")), + createImage(listOf("blue_archive", "hoshino_(blue_archive)", "halo")), + createImage(listOf("blue_archive", "hoshino_(blue_archive)", "halo")), + createImage(listOf("arknights", "silverash_(arknights)", "sword")), + createImage(listOf("arknights", "silverash_(arknights)", "sword")), + createImage(listOf("arknights", "silverash_(arknights)", "sword")) + ) + + val recommended = RecommendationsHelper.getRecommendedTags( + images = images, + selectionSize = 2, + poolSize = 10 + ) + + // Resultant tags should all be either BA-related or Arknights-related, but not mixed. + val cluster1 = setOf("blue_archive", "hoshino_(blue_archive)", "halo") + val cluster2 = setOf("arknights", "silverash_(arknights)", "sword") + + assertTrue("Recommended tags: $recommended", + recommended.all { it in cluster1 } || recommended.all { it in cluster2 } + ) + } + + + @Test + fun testEmptyImages() { + val recommended = RecommendationsHelper.getRecommendedTags( + images = emptyList(), + selectionSize = 2, + poolSize = 10 + ) + assertTrue(recommended.isEmpty()) + } +} diff --git a/gradle.properties b/gradle.properties index 3c5031eb..a626f610 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,14 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b73d782..ae077e37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,27 @@ [versions] -android-gradle-plugin = "8.11.1" -kotlin = "2.2.21" +android-gradle-plugin = "9.2.1" +kotlin = "2.4.0" aboutlibraries = "13.1.0" compose-compiler = "1.5.18" -okhttp = "5.3.2" -kotlin-bom = "2.2.21" -activity-compose = "1.12.1" -core-ktx = "1.17.0" -compose-bom = "2026.03.01" -material3 = "1.5.0-alpha16" +okhttp = "5.4.0" +kotlin-bom = "2.4.0" +activity-compose = "1.13.0" +core-ktx = "1.19.0" +compose-bom = "2026.06.00" +material3 = "1.5.0-alpha22" documentfile = "1.1.0" -navigation-compose = "2.9.6" -paging-compose = "3.3.6" -datastore-preferences = "1.2.0" -coil = "3.3.0" -telephoto = "0.18.0" -kotlinx-serialization = "1.9.0" -reorderable = "3.0.0" +navigation-compose = "2.9.8" +paging-compose = "3.5.0" +datastore-preferences = "1.2.1" +coil = "3.5.0" +telephoto = "0.19.0" +kotlinx-serialization = "1.11.0" +reorderable = "3.1.0" graphics-shapes = "1.1.0" junit = "4.13.2" test-ext-junit = "1.3.0" espresso-core = "3.7.0" -composemediaplayer = "0.8.7" +composemediaplayer = "0.10.0" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0cf0a7d9..fff93f6b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Jul 21 03:45:27 BST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists