Skip to content

Commit a671c45

Browse files
committed
OONI articles
1 parent 9b4dcbf commit a671c45

File tree

29 files changed

+1119
-67
lines changed

29 files changed

+1119
-67
lines changed

composeApp/src/commonMain/composeResources/values/strings-common.xml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@
4242
</plurals>
4343
<string name="Dashboard_Stats_Empty">Start running tests to see your statistics here.</string>
4444

45+
<string name="Dashboard_Articles_Title">OONI News</string>
46+
<string name="Dashboard_Articles_Blog">Blog Post</string>
47+
<string name="Dashboard_Articles_Finding">Finding</string>
48+
<string name="Dashboard_Articles_Report">Report</string>
49+
<string name="Dashboard_Articles_ReadMore">Read More</string>
50+
<string name="Dashboard_Articles_Recent">Recent</string>
51+
4552
<string name="Dashboard_RunV2_Ooni_Title">OONI Tests</string>
4653
<string name="Dashboard_RunV2_Title">OONI Run Links</string>
4754
<string name="Dashboard_Runv2_Overview_Description">Created by %1$s on %2$s</string>
@@ -92,11 +99,9 @@
9299
<string name="Test_Tor_Fullname">Tor Test</string>
93100
<string name="Test_Signal_Fullname">Signal Test</string>
94101

95-
<!-- Test Results -->
102+
<!-- Results -->
96103

97-
<string name="TestResults_Overview_Title">Test Results</string>
98-
<string name="TestResults_Overview_Tab_Label">Test Results</string>
99-
<string name="TestResults_Overview_Hero_Results">Results</string>
104+
<string name="TestResults">Results</string>
100105
<string name="TestResults_Overview_Hero_Networks">Networks</string>
101106
<string name="TestResults_Overview_Hero_DataUsage">Data Usage</string>
102107
<plurals name="TestResults_Overview_Websites_Blocked">

composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ fun App(
125125
LaunchedEffect(Unit) {
126126
dependencies.finishInProgressData()
127127
dependencies.deleteOldResults()
128+
dependencies.refreshArticles()
128129
}
129130
LaunchedEffect(Unit) {
130131
dependencies.observeAndConfigureAutoRun()

composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface OrganizationConfigInterface {
1010
val updateDescriptorTaskId: String
1111
val hasWebsitesDescriptor: Boolean
1212
val donateUrl: String?
13+
val hasOoniNews: Boolean
1314

1415
val ooniApiBaseUrl get() = BuildTypeDefaults.ooniApiBaseUrl
1516
val ooniRunDomain get() = BuildTypeDefaults.ooniRunDomain
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package org.ooni.probe.data.models
2+
3+
import kotlinx.datetime.DateTimeUnit
4+
import kotlinx.datetime.LocalDate
5+
import kotlinx.datetime.LocalDateTime
6+
import kotlinx.datetime.minus
7+
import org.ooni.probe.shared.today
8+
9+
data class ArticleModel(
10+
val url: Url,
11+
val title: String,
12+
val description: String?,
13+
val source: Source,
14+
val time: LocalDateTime,
15+
) {
16+
data class Url(
17+
val value: String,
18+
)
19+
20+
val isRecent get() = time.date >= LocalDate.today().minus(7, DateTimeUnit.DAY)
21+
22+
enum class Source(
23+
val value: String,
24+
) {
25+
Blog("blog"),
26+
Finding("finding"),
27+
Report("report"),
28+
;
29+
30+
companion object {
31+
fun fromValue(value: String) = entries.firstOrNull { it.value == value }
32+
}
33+
}
34+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package org.ooni.probe.data.repositories
2+
3+
import app.cash.sqldelight.coroutines.asFlow
4+
import app.cash.sqldelight.coroutines.mapToList
5+
import kotlinx.coroutines.flow.Flow
6+
import kotlinx.coroutines.flow.map
7+
import kotlinx.coroutines.withContext
8+
import org.ooni.probe.Database
9+
import org.ooni.probe.data.Article
10+
import org.ooni.probe.data.models.ArticleModel
11+
import org.ooni.probe.shared.toEpoch
12+
import org.ooni.probe.shared.toLocalDateTime
13+
import kotlin.coroutines.CoroutineContext
14+
15+
class ArticleRepository(
16+
private val database: Database,
17+
private val backgroundContext: CoroutineContext,
18+
) {
19+
suspend fun refresh(models: List<ArticleModel>) {
20+
withContext(backgroundContext) {
21+
database.transaction {
22+
models.forEach { model ->
23+
database.articleQueries.insertOrReplace(
24+
url = model.url.value,
25+
title = model.title,
26+
description = model.description,
27+
source = model.source.value,
28+
time = model.time.toEpoch(),
29+
)
30+
}
31+
database.articleQueries.deleteExceptUrls(models.map { it.url.value })
32+
}
33+
}
34+
}
35+
36+
fun list(): Flow<List<ArticleModel>> =
37+
database.articleQueries
38+
.selectAll()
39+
.asFlow()
40+
.mapToList(backgroundContext)
41+
.map { list -> list.mapNotNull { it.toModel() } }
42+
43+
private fun Article.toModel() =
44+
run {
45+
ArticleModel(
46+
url = ArticleModel.Url(url),
47+
title = title,
48+
description = description,
49+
source = ArticleModel.Source.fromValue(source) ?: return@run null,
50+
time = time.toLocalDateTime(),
51+
)
52+
}
53+
}

composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import org.ooni.probe.data.disk.ReadFile
2828
import org.ooni.probe.data.disk.ReadFileOkio
2929
import org.ooni.probe.data.disk.WriteFile
3030
import org.ooni.probe.data.disk.WriteFileOkio
31+
import org.ooni.probe.data.models.ArticleModel
3132
import org.ooni.probe.data.models.AutoRunParameters
3233
import org.ooni.probe.data.models.BatteryState
3334
import org.ooni.probe.data.models.InstalledTestDescriptorModel
@@ -38,6 +39,7 @@ import org.ooni.probe.data.models.PreferenceCategoryKey
3839
import org.ooni.probe.data.models.ResultModel
3940
import org.ooni.probe.data.models.RunSpecification
4041
import org.ooni.probe.data.repositories.AppReviewRepository
42+
import org.ooni.probe.data.repositories.ArticleRepository
4143
import org.ooni.probe.data.repositories.MeasurementRepository
4244
import org.ooni.probe.data.repositories.NetworkRepository
4345
import org.ooni.probe.data.repositories.PreferenceRepository
@@ -72,6 +74,8 @@ import org.ooni.probe.domain.ShouldShowVpnWarning
7274
import org.ooni.probe.domain.UploadMissingMeasurements
7375
import org.ooni.probe.domain.appreview.MarkAppReviewAsShown
7476
import org.ooni.probe.domain.appreview.ShouldShowAppReview
77+
import org.ooni.probe.domain.articles.GetArticles
78+
import org.ooni.probe.domain.articles.RefreshArticles
7579
import org.ooni.probe.domain.descriptors.AcceptDescriptorUpdate
7680
import org.ooni.probe.domain.descriptors.BootstrapTestDescriptors
7781
import org.ooni.probe.domain.descriptors.DeleteTestDescriptor
@@ -95,6 +99,8 @@ import org.ooni.probe.domain.results.GetResults
9599
import org.ooni.probe.shared.PlatformInfo
96100
import org.ooni.probe.shared.monitoring.AppLogger
97101
import org.ooni.probe.shared.monitoring.CrashMonitoring
102+
import org.ooni.probe.ui.articles.ArticleViewModel
103+
import org.ooni.probe.ui.articles.ArticlesViewModel
98104
import org.ooni.probe.ui.choosewebsites.ChooseWebsitesViewModel
99105
import org.ooni.probe.ui.dashboard.DashboardViewModel
100106
import org.ooni.probe.ui.descriptor.DescriptorViewModel
@@ -157,6 +163,9 @@ class Dependencies(
157163

158164
private val appReviewRepository by lazy { AppReviewRepository(dataStore) }
159165

166+
@VisibleForTesting
167+
val articleRepository by lazy { ArticleRepository(database, backgroundContext) }
168+
160169
@VisibleForTesting
161170
val measurementRepository by lazy {
162171
MeasurementRepository(database, json, backgroundContext)
@@ -315,6 +324,9 @@ class Dependencies(
315324
updateState = descriptorUpdateStateManager::update,
316325
)
317326
}
327+
private val getArticles by lazy {
328+
GetArticles(articleRepository::list)
329+
}
318330
val getAutoRunSettings by lazy { GetAutoRunSettings(preferenceRepository::allSettings) }
319331
private val getAutoRunSpecification by lazy {
320332
GetAutoRunSpecification(getTestDescriptors::latest, preferenceRepository)
@@ -486,6 +498,12 @@ class Dependencies(
486498
private val shouldShowVpnWarning by lazy {
487499
ShouldShowVpnWarning(preferenceRepository, networkTypeFinder::invoke)
488500
}
501+
val refreshArticles by lazy {
502+
RefreshArticles(
503+
httpDo = engine::httpDo,
504+
refreshArticlesInDatabase = articleRepository::refresh,
505+
)
506+
}
489507
val runBackgroundStateManager by lazy { RunBackgroundStateManager() }
490508
private val undoRejectedDescriptorUpdate by lazy {
491509
UndoRejectedDescriptorUpdate(
@@ -565,6 +583,27 @@ class Dependencies(
565583
startBackgroundRun = startSingleRunInner,
566584
)
567585

586+
fun articleViewModel(
587+
url: ArticleModel.Url,
588+
onBack: () -> Unit,
589+
) = ArticleViewModel(
590+
url = url,
591+
onBack = onBack,
592+
launchAction = launchAction::invoke,
593+
isWebViewAvailable = isWebViewAvailable,
594+
)
595+
596+
fun articlesViewModel(
597+
onBack: () -> Unit,
598+
goToArticle: (ArticleModel.Url) -> Unit,
599+
) = ArticlesViewModel(
600+
onBack = onBack,
601+
goToArticle = goToArticle,
602+
getArticles = getArticles::invoke,
603+
refreshArticles = refreshArticles::invoke,
604+
canPullToRefresh = platformInfo.canPullToRefresh,
605+
)
606+
568607
fun chooseWebsitesViewModel(
569608
initialUrl: String?,
570609
onBack: () -> Unit,
@@ -585,13 +624,17 @@ class Dependencies(
585624
goToRunTests: () -> Unit,
586625
goToTests: () -> Unit,
587626
goToTestSettings: () -> Unit,
627+
goToArticles: () -> Unit,
628+
goToArticle: (ArticleModel.Url) -> Unit,
588629
) = DashboardViewModel(
589630
goToOnboarding = goToOnboarding,
590631
goToResults = goToResults,
591632
goToRunningTest = goToRunningTest,
592633
goToRunTests = goToRunTests,
593634
goToTests = goToTests,
594635
goToTestSettings = goToTestSettings,
636+
goToArticles = goToArticles,
637+
goToArticle = goToArticle,
595638
getFirstRun = getFirstRun::invoke,
596639
observeRunBackgroundState = runBackgroundStateManager::observeState,
597640
observeTestRunErrors = runBackgroundStateManager::observeErrors,
@@ -602,6 +645,7 @@ class Dependencies(
602645
getPreference = preferenceRepository::getValueByKey,
603646
setPreference = preferenceRepository::setValueByKey,
604647
getStats = getStats::invoke,
648+
getArticles = getArticles::invoke,
605649
batteryOptimization = batteryOptimization,
606650
)
607651

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.ooni.probe.domain.articles
2+
3+
import kotlinx.coroutines.flow.Flow
4+
import org.ooni.probe.data.models.ArticleModel
5+
6+
class GetArticles(
7+
val getArticles: () -> Flow<List<ArticleModel>>,
8+
) {
9+
operator fun invoke() = getArticles()
10+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.ooni.probe.domain.articles
2+
3+
import kotlinx.datetime.LocalDateTime
4+
import kotlinx.datetime.format.FormatStringsInDatetimeFormats
5+
import kotlinx.serialization.SerialName
6+
import kotlinx.serialization.Serializable
7+
import kotlinx.serialization.json.Json
8+
import org.ooni.engine.Engine.MkException
9+
import org.ooni.engine.models.Failure
10+
import org.ooni.engine.models.Result
11+
import org.ooni.engine.models.Success
12+
import org.ooni.engine.models.TaskOrigin
13+
import org.ooni.probe.data.models.ArticleModel
14+
import org.ooni.probe.shared.toLocalDateTime
15+
import kotlin.time.Instant
16+
17+
class GetFindings(
18+
val httpDo: suspend (String, String, TaskOrigin) -> Result<String?, MkException>,
19+
) : RefreshArticles.Source {
20+
override suspend operator fun invoke(): Result<List<ArticleModel>, Exception> {
21+
return httpDo("GET", "https://api.ooni.org/api/v1/incidents/search", TaskOrigin.OoniRun)
22+
.mapError { Exception("Failed to get findings", it) }
23+
.flatMap { response ->
24+
if (response.isNullOrBlank()) return@flatMap Failure(Exception("Empty response"))
25+
26+
val wrapper = try {
27+
Json.decodeFromString<Wrapper>(response)
28+
} catch (e: Exception) {
29+
return@flatMap Failure(Exception("Could not parse indidents API response", e))
30+
}
31+
32+
Success(wrapper.incidents?.mapNotNull { it.toArticle() }.orEmpty())
33+
}
34+
}
35+
36+
private fun Wrapper.Incident.toArticle() =
37+
run {
38+
ArticleModel(
39+
url = id?.let { ArticleModel.Url("https://explorer.ooni.org/findings/$it") }
40+
?: return@run null,
41+
title = title ?: return@run null,
42+
source = ArticleModel.Source.Finding,
43+
description = shortDescription,
44+
time = createTime?.toLocalDateTime() ?: return@run null,
45+
)
46+
}
47+
48+
@OptIn(FormatStringsInDatetimeFormats::class)
49+
private fun String.toLocalDateTime(): LocalDateTime? = Instant.parse(this).toLocalDateTime()
50+
51+
companion object {
52+
private val Json by lazy {
53+
Json {
54+
ignoreUnknownKeys = true
55+
}
56+
}
57+
}
58+
59+
@Serializable
60+
data class Wrapper(
61+
@SerialName("incidents")
62+
val incidents: List<Incident>?,
63+
) {
64+
@Serializable
65+
data class Incident(
66+
@SerialName("id") val id: String?,
67+
@SerialName("title") val title: String?,
68+
@SerialName("short_description") val shortDescription: String?,
69+
@SerialName("create_time") val createTime: String?,
70+
)
71+
}
72+
}

0 commit comments

Comments
 (0)