Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7ea8854
feat: model the recommender profile, data, trace and opinions
Priveetee Oct 26, 2026
c91bbcc
feat: read the recommender profile, data, trace and opinions
Priveetee Oct 26, 2026
dda128f
test: cover the recommender json readers
Priveetee Oct 26, 2026
d86bbe4
feat: add the recommendation screen strings
Priveetee Oct 26, 2026
9850914
feat: add the recommendation screen plurals
Priveetee Oct 26, 2026
629c4b2
feat: keep recommendation settings state and its edits
Priveetee Oct 26, 2026
cb886a7
feat: load and save recommendation settings section by section
Priveetee Oct 26, 2026
2e87832
feat: share recommendation groups, rows and setting controls
Priveetee Oct 26, 2026
0c74665
feat: chart what the home page and interests are made of
Priveetee Oct 26, 2026
7eb2955
feat: choose the algorithm and see the data it reads
Priveetee Oct 26, 2026
6d25c7b
feat: add the algorithm guide and opinions sections
Priveetee Oct 26, 2026
8a1f51a
feat: review and limit the data the algorithm reads
Priveetee Oct 26, 2026
e12a2dc
feat: review interests, channels and recent videos
Priveetee Oct 26, 2026
dff92f9
feat: correct an interest or a channel from its card
Priveetee Oct 26, 2026
0861422
feat: show every setting and how the last page was built
Priveetee Oct 26, 2026
46c765b
feat: open recommendations from their own settings entry
Priveetee Oct 26, 2026
a472c0d
refactor: move recommendations out of content settings
Priveetee Oct 26, 2026
4e50eb0
chore: merge beta into dev
Priveetee Oct 26, 2026
5e7f166
chore: prepare 1.11.0 beta 9
Priveetee Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = 11108
versionName = "1.11.0-beta.8"
versionCode = 11109
versionName = "1.11.0-beta.9"
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute
import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute
import dev.typetype.android.core.ui.navigation.RssFeedsRoute
import dev.typetype.android.core.ui.navigation.PushNotificationsRoute
import dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute
import dev.typetype.android.core.ui.navigation.SettingsRoute
import dev.typetype.android.core.ui.navigation.StorageSettingsRoute
import dev.typetype.android.feature.settings.SettingsRoute as SettingsMenu
Expand Down Expand Up @@ -67,6 +68,7 @@ internal fun AdaptiveSettingsHost(
onOpenRssFeeds = { navController.selectSettings(RssFeedsRoute) },
onOpenAppearance = { navController.selectSettings(AppearanceRoute) },
onOpenContent = { navController.selectSettings(ContentSettingsRoute) },
onOpenRecommendations = { navController.selectSettings(RecommenderSettingsRoute) },
onOpenPlayer = { navController.selectSettings(PlayerSettingsRoute) },
onOpenPushNotifications = { navController.selectSettings(PushNotificationsRoute) },
onOpenStorage = { navController.selectSettings(StorageSettingsRoute) },
Expand Down Expand Up @@ -105,6 +107,7 @@ private fun NavDestination.settingsTitle(): Int? = when {
hasRoute<ImportDataRoute>() -> R.string.settings_import_title
hasRoute<RssFeedsRoute>() -> R.string.rss_settings_title
hasRoute<ContentSettingsRoute>() -> R.string.settings_content_title
hasRoute<RecommenderSettingsRoute>() -> R.string.algo_title
hasRoute<PlayerSettingsRoute>() -> R.string.settings_player_title
hasRoute<StorageSettingsRoute>() -> R.string.settings_storage_title
hasRoute<PrivacySettingsRoute>() -> R.string.settings_privacy_title
Expand Down
3 changes: 0 additions & 3 deletions app/src/main/java/dev/typetype/android/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,6 @@ 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> {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/dev/typetype/android/SettingsNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import dev.typetype.android.core.ui.navigation.LicensesRoute
import dev.typetype.android.core.ui.navigation.PlayerSettingsRoute
import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute
import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute
import dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute
import dev.typetype.android.core.ui.navigation.RssFeedsRoute
import dev.typetype.android.core.ui.navigation.SettingsRoute
import dev.typetype.android.core.ui.navigation.StorageSettingsRoute
Expand Down Expand Up @@ -41,6 +42,7 @@ internal fun NavGraphBuilder.settingsDestinations(
onOpenRssFeeds = { navController.navigate(RssFeedsRoute) },
onOpenAppearance = { navController.navigate(AppearanceRoute) },
onOpenContent = { navController.navigate(ContentSettingsRoute) },
onOpenRecommendations = { navController.navigate(RecommenderSettingsRoute) },
onOpenPlayer = { navController.navigate(PlayerSettingsRoute) },
onOpenPushNotifications = { navController.navigate(dev.typetype.android.core.ui.navigation.PushNotificationsRoute) },
onOpenStorage = { navController.navigate(StorageSettingsRoute) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ interface TypeTypeRecommenderApi {
@GET("recommendations/data")
suspend fun recommenderData(): Response<JsonArray>

@GET("recommendations/last-home")
suspend fun recommenderLastHome(): Response<JsonObject>

@GET("recommendations/feedback")
suspend fun recommenderFeedback(): 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
Expand Up @@ -4,24 +4,17 @@ 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() },
algorithms = objects("algorithms").mapNotNull { it.toAlgorithm() },
)

private fun JsonObject.toAlgorithm(): AlgorithmInfo? {
Expand All @@ -40,7 +33,10 @@ private fun JsonObject.toAlgorithm(): AlgorithmInfo? {
discovery = traits?.int("discovery") ?: 0,
variety = traits?.int("variety") ?: 0,
),
parameters = array("parameters").mapNotNull { (it as? JsonObject)?.toParameter() },
parameters = objects("parameters").mapNotNull { it.toParameter() },
uses = strings("uses"),
sources = strings("sources"),
sourceUrl = string("source_url")?.takeIf { it.startsWith("https://") },
)
}

Expand All @@ -49,7 +45,7 @@ private fun JsonObject.toParameter(): AlgorithmParameter? {
val group = string("group").orEmpty()
val label = string("label") ?: id
val help = string("help")
val featured = this["featured"]?.jsonPrimitive?.booleanOrNull == true
val featured = bool("featured") == true
return when (string("type")) {
"number" -> AlgorithmParameter.Number(
id = id,
Expand All @@ -69,14 +65,14 @@ private fun JsonObject.toParameter(): AlgorithmParameter? {
label = label,
help = help,
featured = featured,
default = this["default"]?.jsonPrimitive?.booleanOrNull == true,
default = bool("default") == true,
)
else -> null
}
}

internal fun JsonObject.toPreferences(): RecommenderPreferences = RecommenderPreferences(
enabled = this["enabled"]?.jsonPrimitive?.booleanOrNull == true,
enabled = bool("enabled") == true,
algorithmId = string("algorithmId"),
parameters = obj("parameters").orEmpty().mapNotNull { (key, value) ->
val primitive = value as? JsonPrimitive ?: return@mapNotNull null
Expand All @@ -85,66 +81,35 @@ internal fun JsonObject.toPreferences(): RecommenderPreferences = RecommenderPre
}.toMap(),
pinnedGroups = strings("pinnedGroups"),
mutedGroups = strings("mutedGroups"),
ignoredSignals = strings("ignoredSignals"),
sessionResetAt = long("sessionResetAt"),
customInterests = strings("customInterests"),
unusedData = strings("unusedData"),
excludedItems = strings("excludedItems"),
retentionDays = obj("retentionDays").orEmpty().mapNotNull { (key, value) ->
(value as? JsonPrimitive)?.intOrNull?.let { key to it }
}.toMap(),
)

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("parameters", JsonObject(preferences.parameters.mapValues { (_, value) -> value.toJsonValue() }))
put("pinnedGroups", preferences.pinnedGroups.toJsonArray())
put("mutedGroups", preferences.mutedGroups.toJsonArray())
put("ignoredSignals", preferences.ignoredSignals.toJsonArray())
put("sessionResetAt", preferences.sessionResetAt?.let(::JsonPrimitive) ?: JsonNull)
put("customInterests", preferences.customInterests.toJsonArray())
put("unusedData", preferences.unusedData.toJsonArray())
put("excludedItems", preferences.excludedItems.toJsonArray())
put("retentionDays", JsonObject(preferences.retentionDays.mapValues { (_, days) -> JsonPrimitive(days) }))
},
)

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 Any.toJsonValue(): JsonPrimitive = when (this) {
is Boolean -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
else -> JsonPrimitive(toString())
}

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 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package dev.typetype.android.data.recommendations

import dev.typetype.android.domain.recommendations.DataCategory
import dev.typetype.android.domain.recommendations.DataItem
import dev.typetype.android.domain.recommendations.FeedbackEntry
import dev.typetype.android.domain.recommendations.InterestExample
import dev.typetype.android.domain.recommendations.InterestGroup
import dev.typetype.android.domain.recommendations.PageTrace
import dev.typetype.android.domain.recommendations.ProfileChannel
import dev.typetype.android.domain.recommendations.RecommenderProfile
import dev.typetype.android.domain.recommendations.SessionBoost
import dev.typetype.android.domain.recommendations.TraceRow
import dev.typetype.android.domain.recommendations.TraceScore
import dev.typetype.android.domain.recommendations.TraceSource
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull

internal fun JsonObject.toProfile(): RecommenderProfile = RecommenderProfile(
groups = objects("groups").mapNotNull { it.toInterestGroup() },
channels = objects("channels").mapNotNull { channel ->
ProfileChannel(
id = channel.string("id") ?: return@mapNotNull null,
name = channel.string("name").orEmpty(),
weight = channel.double("weight") ?: 0.0,
basedOn = channel.array("from").size,
avatarUrl = channel.string("avatarUrl"),
)
},
sessionBoosts = objects("session_boosts").mapNotNull { boost ->
SessionBoost(
ref = boost.string("ref") ?: return@mapNotNull null,
label = boost.string("label").orEmpty(),
expiresAt = boost.long("expires_at") ?: 0L,
)
},
)

private fun JsonObject.toInterestGroup(): InterestGroup? = InterestGroup(
id = string("id") ?: return null,
label = string("label").orEmpty(),
weight = double("weight") ?: 0.0,
basedOn = array("from").size,
pinned = bool("pinned") == true,
muted = bool("muted") == true,
examples = objects("examples").mapNotNull { example ->
InterestExample(
url = example.string("url") ?: return@mapNotNull null,
title = example.string("title"),
thumbnailUrl = example.string("thumbnailUrl"),
)
},
)

internal fun JsonArray.toDataCategories(): List<DataCategory> = filterIsInstance<JsonObject>().mapNotNull { category ->
DataCategory(
id = category.string("id") ?: return@mapNotNull null,
count = category.int("count") ?: 0,
used = category.bool("use") != false,
retentionDays = category.int("retentionDays"),
collect = category.bool("collect"),
items = category.objects("items").mapNotNull { it.toDataItem() },
)
}

private fun JsonObject.toDataItem(): DataItem? = DataItem(
id = string("id") ?: return null,
title = string("title").orEmpty(),
channel = string("channel"),
at = long("at") ?: 0L,
watchedPercent = int("watchedPercent"),
excluded = bool("excluded") == true,
thumbnailUrl = string("thumbnailUrl"),
viewings = int("viewings") ?: 1,
)

internal fun JsonObject.toPageTrace(): PageTrace = PageTrace(
algorithm = string("algorithm").orEmpty(),
builtAt = long("builtAt") ?: 0L,
sources = objects("sources").map { TraceSource(it.string("kind").orEmpty(), it.int("received") ?: 0) },
candidates = int("candidatesReceived") ?: 0,
dropped = obj("dropped").orEmpty().mapNotNull { (why, count) ->
(count as? JsonPrimitive)?.intOrNull?.let { why to it }
}.toMap(),
shown = int("shown") ?: 0,
rows = objects("rows").map { row ->
TraceRow(row.string("id").orEmpty(), row.string("title").orEmpty(), row.string("why").orEmpty(), row.int("videos") ?: 0)
},
scores = objects("scores").map { score ->
TraceScore(
url = score.string("url").orEmpty(),
title = score.string("title").orEmpty(),
channel = score.string("channel").orEmpty(),
row = score.string("row").orEmpty(),
total = score.double("total") ?: 0.0,
factors = score.obj("factors").orEmpty().mapNotNull { (factor, value) ->
(value as? JsonPrimitive)?.doubleOrNull?.let { factor to it }
}.toMap(),
)
},
)

internal fun JsonArray.toFeedbackEntries(): List<FeedbackEntry> = filterIsInstance<JsonObject>().mapNotNull { entry ->
FeedbackEntry(
id = entry.string("id")?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null,
kind = entry.string("kind").orEmpty(),
verdict = entry.string("verdict").orEmpty(),
comment = entry.string("comment"),
reason = entry.string("reason"),
title = entry.string("title"),
channelName = entry.string("channelName"),
videoUrl = entry.string("videoUrl"),
thumbnailUrl = entry.string("thumbnailUrl"),
algorithmId = entry.string("algorithmId").orEmpty(),
createdAt = entry.long("createdAt") ?: 0L,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.typetype.android.data.recommendations

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
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.longOrNull

internal fun JsonObject.string(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content

internal fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull

internal fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.let { it.longOrNull ?: it.doubleOrNull?.toLong() }

internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull

internal fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull

internal fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject

internal fun JsonObject.array(key: String): List<JsonElement> = (this[key] as? JsonArray).orEmpty()

internal fun JsonObject.objects(key: String): List<JsonObject> = array(key).filterIsInstance<JsonObject>()

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

internal fun List<String>.toJsonArray(): JsonArray = JsonArray(map(::JsonPrimitive))
Loading