Skip to content
Merged
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/dev/typetype/android/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -238,8 +239,14 @@ fun AppNavHost(
composable<ContentSettingsRoute> {
ContentSettingsRouteScreen(
onNavigateBack = { navController.popBackStack() },
onOpenRecommender = {
navController.navigate(dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute)
},
)
}
composable<dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute> {
RecommenderSettingsRouteScreen(onNavigateBack = { navController.popBackStack() })
}
composable<PlayerSettingsRoute> {
PlayerSettingsRouteScreen(
onNavigateBack = { navController.popBackStack() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -54,6 +57,7 @@ class HomeFeedRepositoryImpl @Inject constructor(
override suspend fun loadHomeRecommendations(
cursor: String?,
limit: Int,
service: Int,
): Result<HomeRecommendationsPage> = feedResult {
val scope = activeAccountScope.require()
val server = if (scope.accountId.startsWith(GUEST_ACCOUNT_PREFIX)) {
Expand All @@ -67,14 +71,26 @@ 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")
activeAccountScope.verify(scope)
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,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HomeRecommendationsResponse>

@GET("recommendations/shorts")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
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")
suspend fun recommenderSettings(): Response<JsonObject>

@PUT("recommendations/settings")
suspend fun updateRecommenderSettings(@Body settings: JsonObject): Response<JsonObject>

@POST("recommendations/feedback")
suspend fun sendRecommenderFeedback(
@Body feedback: RecommenderFeedbackRequestDto,
): Response<RecommenderFeedbackDto>

@GET("recommendations/algorithms")
suspend fun recommenderAlgorithms(
@Query("locale") locale: String,
@Query("timezone") timezone: String,
): Response<JsonObject>

@GET("recommendations/profile")
suspend fun recommenderProfile(
@Query("locale") locale: String,
@Query("timezone") timezone: String,
): Response<JsonObject>

@GET("recommendations/data")
suspend fun recommenderData(): Response<JsonArray>

@DELETE("recommendations/feedback/{id}")
suspend fun deleteRecommenderFeedback(@Path("id") id: String): Response<Unit>
}
Original file line number Diff line number Diff line change
@@ -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 = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ data class HomeRecommendationsResponse(
val items: List<VideoItem> = emptyList(),
val nextCursor: String? = null,
val hasMore: Boolean = false,
val algorithm: String? = null,
val preparing: Boolean? = null,
val rows: List<HomeRecommendationRowDto>? = null,
)

@Serializable
data class HomeRecommendationRowDto(
val id: String,
val title: String,
val reason: String = "",
val reasonRef: String? = null,
val videoUrls: List<String> = emptyList(),
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InterestGroup> = 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<DataFamily> = 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<String>.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<JsonElement> = (this[key] as? JsonArray).orEmpty()

private fun JsonObject.strings(key: String): List<String> =
array(key).mapNotNull { (it as? JsonPrimitive)?.takeIf { primitive -> primitive.isString }?.content }
Loading