1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
package org.terst.feedreader.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.StashSaveOutcome
import org.terst.feedreader.data.ensureStandaloneArticle
import org.terst.feedreader.data.saveArticleToStash
private val URL_REGEX = Regex("""https?://\S+""")
/**
* Share-sheet target: a URL shared from any other app lands here. Extracts
* the article on-device (so it's readable offline immediately, independent
* of Stash), stores it locally as a standalone saved article (feedId=null,
* not tied to any subscription), and best-effort archives it to Stash.
*/
class ShareReceiverActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sharedText = intent?.getStringExtra(android.content.Intent.EXTRA_TEXT).orEmpty()
val url = URL_REGEX.find(sharedText)?.value
setContent {
MaterialTheme {
ShareReceiverContent(url = url, onDone = { finish() })
}
}
}
}
@Composable
private fun ShareReceiverContent(url: String?, onDone: () -> Unit) {
val context = LocalContext.current
var status by remember { mutableStateOf(if (url == null) "No link found in shared content" else null) }
var done by remember { mutableStateOf(url == null) }
// Requires an explicit tap before touching the network -- this activity is exported
// (any installed app can target it directly with an ACTION_SEND intent, not just via
// the system share sheet), and starting extraction/Stash-save immediately on launch
// meant ANY other app could silently make this one fetch+JS-render an arbitrary URL
// and archive it to the user's own Stash server with zero user interaction. A tap
// here is the only thing standing between "another app sent an intent" and "this app
// did real network work," so it can't be skipped just because url is non-null.
var confirmed by remember { mutableStateOf(false) }
LaunchedEffect(url, confirmed) {
if (url == null || !confirmed) return@LaunchedEffect
status = "Saving…"
val db = AppDatabase.get(context)
val entity = ensureStandaloneArticle(context, db, url)
if (!entity.isSaved) db.articleDao().setSaved(entity.id, true)
status = when (val outcome = saveArticleToStash(context, db, entity.id, url, entity.title)) {
is StashSaveOutcome.Saved -> "Saved for offline reading and archived to Stash"
is StashSaveOutcome.AlreadySaved -> "Saved for offline reading (already in Stash)"
is StashSaveOutcome.Failed -> "Saved for offline reading. Stash archive failed: ${outcome.reason}"
}
done = true
}
Column(modifier = Modifier.padding(24.dp)) {
Card(modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.padding(20.dp)) {
Text("Feeds", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
when {
url == null -> {
Text(status ?: "No link found in shared content")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onDone) { Text("Done") }
}
!confirmed -> {
Text(url)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { confirmed = true }) { Text("Save") }
}
else -> {
Text(status ?: "Saving…")
if (!done) {
Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator()
} else {
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onDone) { Text("Done") }
}
}
}
}
}
}
}
|