diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1df6f50c..5fa2baeb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { defaultConfig { applicationId = "dev.typetype.android" - versionCode = 11107 - versionName = "1.11.0-beta.7" + versionCode = 11108 + versionName = "1.11.0-beta.8" } signingConfigs { diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index 44668351..649060e8 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -51,6 +51,7 @@ import dev.typetype.android.feature.settings.accounts.AccountSettingsRoute as Ac import dev.typetype.android.feature.settings.appearance.AppearanceRoute as AppearanceRouteScreen import dev.typetype.android.feature.settings.blocked.BlockedSettingsRoute as BlockedSettingsRouteScreen import dev.typetype.android.feature.settings.content.ContentSettingsRoute as ContentSettingsRouteScreen +import dev.typetype.android.feature.settings.recommender.RecommenderSettingsRoute as RecommenderSettingsRouteScreen import dev.typetype.android.feature.settings.diagnostics.DiagnosticsRoute as DiagnosticsRouteScreen import dev.typetype.android.feature.settings.player.PlayerSettingsRoute as PlayerSettingsRouteScreen import dev.typetype.android.feature.settings.privacy.PrivacySettingsRoute as PrivacySettingsRouteScreen @@ -238,8 +239,14 @@ fun AppNavHost( composable { ContentSettingsRouteScreen( onNavigateBack = { navController.popBackStack() }, + onOpenRecommender = { + navController.navigate(dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute) + }, ) } + composable { + RecommenderSettingsRouteScreen(onNavigateBack = { navController.popBackStack() }) + } composable { PlayerSettingsRouteScreen( onNavigateBack = { navController.popBackStack() }, diff --git a/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt b/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt index 6da500f8..53e4f006 100644 --- a/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt +++ b/core/data/src/main/java/dev/typetype/android/core/di/RecommenderRepositoryModule.kt @@ -4,7 +4,11 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dev.typetype.android.data.recommendations.RemoteRecommenderFeedbackRepository +import dev.typetype.android.data.recommendations.RemoteRecommenderRepository import dev.typetype.android.data.recommendations.RemoteRecommenderSettingsRepository +import dev.typetype.android.domain.recommendations.RecommenderFeedbackRepository +import dev.typetype.android.domain.recommendations.RecommenderRepository import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository import javax.inject.Singleton @@ -16,4 +20,16 @@ abstract class RecommenderRepositoryModule { abstract fun bindRecommenderSettingsRepository( implementation: RemoteRecommenderSettingsRepository, ): RecommenderSettingsRepository + + @Binds + @Singleton + abstract fun bindRecommenderFeedbackRepository( + implementation: RemoteRecommenderFeedbackRepository, + ): RecommenderFeedbackRepository + + @Binds + @Singleton + abstract fun bindRecommenderRepository( + implementation: RemoteRecommenderRepository, + ): RecommenderRepository } diff --git a/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt b/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt index 702b37bd..ed38f3a9 100644 --- a/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt +++ b/core/data/src/main/java/dev/typetype/android/data/feed/HomeFeedRepositoryImpl.kt @@ -1,5 +1,8 @@ package dev.typetype.android.data.feed +import java.util.Locale +import java.util.TimeZone +import dev.typetype.android.domain.feed.HomeRecommendationRow import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.network.TypeTypeApiHolder @@ -54,6 +57,7 @@ class HomeFeedRepositoryImpl @Inject constructor( override suspend fun loadHomeRecommendations( cursor: String?, limit: Int, + service: Int, ): Result = feedResult { val scope = activeAccountScope.require() val server = if (scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX)) { @@ -67,7 +71,14 @@ class HomeFeedRepositoryImpl @Inject constructor( } val api = apiHolder.require(scope) val response = withContext(Dispatchers.IO) { - api.homeRecommendations(intent = "auto", cursor = cursor, limit = limit) + api.homeRecommendations( + service = service, + intent = "auto", + cursor = cursor, + limit = limit, + locale = Locale.getDefault().toLanguageTag(), + timezone = TimeZone.getDefault().id, + ) } response.requireSuccessfulResponse() val body = response.body() ?: error("Empty recommendations body") @@ -75,6 +86,11 @@ class HomeFeedRepositoryImpl @Inject constructor( HomeRecommendationsPage( videos = body.items.map { it.toDomainVideo() }, nextCursor = body.nextCursor?.takeIf { body.hasMore && it.isNotBlank() }, + rows = body.rows.orEmpty().map { row -> + HomeRecommendationRow(row.id, row.title, row.reason, row.reasonRef, row.videoUrls) + }, + algorithm = body.algorithm, + preparing = body.preparing == true, ) } diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt index d3a687df..e1cf1c38 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeFeedApi.kt @@ -27,6 +27,8 @@ interface TypeTypeFeedApi { @Query("limit") limit: Int = 30, @Query("intent") intent: String? = null, @Query("cursor") cursor: String? = null, + @Query("locale") locale: String? = null, + @Query("timezone") timezone: String? = null, ): Response @GET("recommendations/shorts") diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt index d895387a..f0be4014 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt @@ -1,10 +1,17 @@ package dev.typetype.android.data.network +import dev.typetype.android.data.network.dto.RecommenderFeedbackDto +import dev.typetype.android.data.network.dto.RecommenderFeedbackRequestDto +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query interface TypeTypeRecommenderApi { @GET("recommendations/settings") @@ -12,4 +19,27 @@ interface TypeTypeRecommenderApi { @PUT("recommendations/settings") suspend fun updateRecommenderSettings(@Body settings: JsonObject): Response + + @POST("recommendations/feedback") + suspend fun sendRecommenderFeedback( + @Body feedback: RecommenderFeedbackRequestDto, + ): Response + + @GET("recommendations/algorithms") + suspend fun recommenderAlgorithms( + @Query("locale") locale: String, + @Query("timezone") timezone: String, + ): Response + + @GET("recommendations/profile") + suspend fun recommenderProfile( + @Query("locale") locale: String, + @Query("timezone") timezone: String, + ): Response + + @GET("recommendations/data") + suspend fun recommenderData(): Response + + @DELETE("recommendations/feedback/{id}") + suspend fun deleteRecommenderFeedback(@Path("id") id: String): Response } diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt new file mode 100644 index 00000000..3c5c6cd3 --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/RecommenderFeedbackDto.kt @@ -0,0 +1,20 @@ +package dev.typetype.android.data.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RecommenderFeedbackRequestDto( + val kind: String, + val verdict: String, + val comment: String? = null, + val videoUrl: String? = null, + val reason: String? = null, + val title: String? = null, + val channelName: String? = null, + val thumbnailUrl: String? = null, +) + +@Serializable +data class RecommenderFeedbackDto( + val id: String = "", +) diff --git a/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt b/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt index 530fdf36..3b74ab89 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/dto/VideoDto.kt @@ -31,6 +31,18 @@ data class HomeRecommendationsResponse( val items: List = emptyList(), val nextCursor: String? = null, val hasMore: Boolean = false, + val algorithm: String? = null, + val preparing: Boolean? = null, + val rows: List? = null, +) + +@Serializable +data class HomeRecommendationRowDto( + val id: String, + val title: String, + val reason: String = "", + val reasonRef: String? = null, + val videoUrls: List = emptyList(), ) @Serializable diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt new file mode 100644 index 00000000..e39b4e7f --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt @@ -0,0 +1,150 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.domain.recommendations.AlgorithmCatalog +import dev.typetype.android.domain.recommendations.AlgorithmInfo +import dev.typetype.android.domain.recommendations.AlgorithmParameter +import dev.typetype.android.domain.recommendations.AlgorithmTraits +import dev.typetype.android.domain.recommendations.DataFamily +import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.RecommenderPreferences +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal fun JsonObject.toCatalog(): AlgorithmCatalog = AlgorithmCatalog( + defaultId = string("default_algorithm"), + algorithms = array("algorithms").mapNotNull { (it as? JsonObject)?.toAlgorithm() }, +) + +private fun JsonObject.toAlgorithm(): AlgorithmInfo? { + val id = string("id") ?: return null + val guide = obj("guide") + val traits = guide?.obj("traits") + return AlgorithmInfo( + id = id, + name = string("name") ?: id, + description = string("description").orEmpty(), + forYouIf = guide?.string("for_you_if").orEmpty(), + youWillSee = guide?.string("you_will_see").orEmpty(), + goodToKnow = guide?.string("good_to_know").orEmpty(), + traits = AlgorithmTraits( + personal = traits?.int("personal") ?: 0, + discovery = traits?.int("discovery") ?: 0, + variety = traits?.int("variety") ?: 0, + ), + parameters = array("parameters").mapNotNull { (it as? JsonObject)?.toParameter() }, + ) +} + +private fun JsonObject.toParameter(): AlgorithmParameter? { + val id = string("id") ?: return null + val group = string("group").orEmpty() + val label = string("label") ?: id + val help = string("help") + val featured = this["featured"]?.jsonPrimitive?.booleanOrNull == true + return when (string("type")) { + "number" -> AlgorithmParameter.Number( + id = id, + group = group, + label = label, + help = help, + featured = featured, + min = double("min") ?: 0.0, + max = double("max") ?: 1.0, + step = double("step") ?: 1.0, + default = double("default") ?: 0.0, + unit = string("unit"), + ) + "boolean" -> AlgorithmParameter.Toggle( + id = id, + group = group, + label = label, + help = help, + featured = featured, + default = this["default"]?.jsonPrimitive?.booleanOrNull == true, + ) + else -> null + } +} + +internal fun JsonObject.toPreferences(): RecommenderPreferences = RecommenderPreferences( + enabled = this["enabled"]?.jsonPrimitive?.booleanOrNull == true, + algorithmId = string("algorithmId"), + parameters = obj("parameters").orEmpty().mapNotNull { (key, value) -> + val primitive = value as? JsonPrimitive ?: return@mapNotNull null + val parsed: Any = primitive.booleanOrNull ?: primitive.doubleOrNull ?: return@mapNotNull null + key to parsed + }.toMap(), + pinnedGroups = strings("pinnedGroups"), + mutedGroups = strings("mutedGroups"), + customInterests = strings("customInterests"), + unusedData = strings("unusedData"), +) + +internal fun JsonObject.withPreferences(preferences: RecommenderPreferences): JsonObject = + JsonObject( + toMutableMap().apply { + put("enabled", JsonPrimitive(preferences.enabled)) + put("algorithmId", preferences.algorithmId?.let(::JsonPrimitive) ?: JsonNull) + put( + "parameters", + JsonObject( + preferences.parameters.mapValues { (_, value) -> + when (value) { + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + else -> JsonPrimitive(value.toString()) + } + }, + ), + ) + put("pinnedGroups", preferences.pinnedGroups.toJsonArray()) + put("mutedGroups", preferences.mutedGroups.toJsonArray()) + put("customInterests", preferences.customInterests.toJsonArray()) + put("unusedData", preferences.unusedData.toJsonArray()) + }, + ) + +internal fun JsonObject.toInterests(): List = array("groups").mapNotNull { element -> + val group = element as? JsonObject ?: return@mapNotNull null + InterestGroup( + id = group.string("id") ?: return@mapNotNull null, + label = group.string("label").orEmpty(), + weight = group.double("weight") ?: 0.0, + pinned = group["pinned"]?.jsonPrimitive?.booleanOrNull == true, + muted = group["muted"]?.jsonPrimitive?.booleanOrNull == true, + ) +} + +internal fun JsonArray.toDataFamilies(): List = mapNotNull { element -> + val family = element as? JsonObject ?: return@mapNotNull null + DataFamily( + id = family.string("id") ?: return@mapNotNull null, + count = family.int("count") ?: 0, + used = family["use"]?.jsonPrimitive?.booleanOrNull != false, + ) +} + +private fun List.toJsonArray(): JsonArray = JsonArray(map(::JsonPrimitive)) + +private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + +private fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull + +private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject + +private fun JsonObject.array(key: String): List = (this[key] as? JsonArray).orEmpty() + +private fun JsonObject.strings(key: String): List = + array(key).mapNotNull { (it as? JsonPrimitive)?.takeIf { primitive -> primitive.isString }?.content } diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt new file mode 100644 index 00000000..345f284c --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderFeedbackRepository.kt @@ -0,0 +1,65 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.core.error.suspendRunCatching +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.dto.RecommenderFeedbackRequestDto +import dev.typetype.android.data.network.requireSuccessfulResponse +import dev.typetype.android.domain.feed.Video +import dev.typetype.android.domain.recommendations.PageRefusalReason +import dev.typetype.android.domain.recommendations.RecommenderFeedbackRepository +import dev.typetype.android.domain.recommendations.VideoRefusalReason +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Singleton +class RemoteRecommenderFeedbackRepository @Inject constructor( + private val apiHolder: TypeTypeApiHolder, + private val activeAccountScope: ActiveAccountScope, +) : RecommenderFeedbackRepository { + override suspend fun notForMe(video: Video, reason: VideoRefusalReason?): Result = send( + RecommenderFeedbackRequestDto( + kind = "video", + verdict = "not_for_me", + videoUrl = video.url, + reason = reason?.wireName, + title = video.title.take(MAX_TEXT), + channelName = video.uploaderName.take(MAX_TEXT), + thumbnailUrl = video.thumbnailUrl.takeIf { it.length <= MAX_URL }, + ), + ) + + override suspend fun ratePage(good: Boolean, reason: PageRefusalReason?, comment: String?): Result = send( + RecommenderFeedbackRequestDto( + kind = "page", + verdict = if (good) "good" else "bad", + reason = reason?.wireName, + comment = comment?.trim()?.takeIf { it.isNotEmpty() }?.take(MAX_COMMENT), + ), + ) + + override suspend fun withdraw(feedbackId: String): Result = suspendRunCatching { + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { + apiHolder.require(scope).deleteRecommenderFeedback(feedbackId) + } + response.requireSuccessfulResponse() + } + + private suspend fun send(request: RecommenderFeedbackRequestDto): Result = suspendRunCatching { + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { + apiHolder.require(scope).sendRecommenderFeedback(request) + } + response.requireSuccessfulResponse() + response.body()?.id.orEmpty() + } + + private companion object { + const val MAX_TEXT = 300 + const val MAX_URL = 500 + const val MAX_COMMENT = 1000 + } +} diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt new file mode 100644 index 00000000..054dc7a7 --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt @@ -0,0 +1,51 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.core.error.suspendRunCatching +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.network.TypeTypeApi +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.requireSuccessfulResponse +import dev.typetype.android.domain.recommendations.AlgorithmCatalog +import dev.typetype.android.domain.recommendations.DataFamily +import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.RecommenderPreferences +import dev.typetype.android.domain.recommendations.RecommenderRepository +import java.util.TimeZone +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.Response + +@Singleton +class RemoteRecommenderRepository @Inject constructor( + private val apiHolder: TypeTypeApiHolder, + private val activeAccountScope: ActiveAccountScope, +) : RecommenderRepository { + override suspend fun catalog(locale: String): Result = + call { recommenderAlgorithms(locale, TimeZone.getDefault().id) }.map { it.toCatalog() } + + override suspend fun preferences(): Result = + call { recommenderSettings() }.map { it.toPreferences() } + + override suspend fun save(preferences: RecommenderPreferences): Result = + suspendRunCatching { + val current = call { recommenderSettings() }.getOrThrow() + call { updateRecommenderSettings(current.withPreferences(preferences)) }.getOrThrow().toPreferences() + } + + override suspend fun interests(locale: String): Result> = + call { recommenderProfile(locale, TimeZone.getDefault().id) }.map { it.toInterests() } + + override suspend fun dataFamilies(): Result> = + call { recommenderData() }.map { it.toDataFamilies() } + + private suspend fun call(request: suspend TypeTypeApi.() -> Response): Result = suspendRunCatching { + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { apiHolder.require(scope).request() } + response.requireSuccessfulResponse() + val body = requireNotNull(response.body()) { "Empty recommender response" } + activeAccountScope.verify(scope) + body + } +} diff --git a/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt b/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt index aca00814..d3d8bf1a 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/feed/HomeFeedRepository.kt @@ -14,6 +14,17 @@ data class SubscriptionsPage( data class HomeRecommendationsPage( val videos: List