Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0d613ed
Specify the page icon that travels in a URL push's envelope
cdeutsch Nov 12, 2026
0e54ff5
Draw web list icons from the push instead of Google's favicon service
cdeutsch Nov 12, 2026
5162c95
Say on the privacy and security pages that a page's icon is encrypted…
cdeutsch Nov 12, 2026
d0aad8f
Carry a link's icon in the Android push instead of asking Google for it
cdeutsch Nov 12, 2026
2f33538
Carry a link's icon in the iOS and macOS pushes instead of asking Goo…
cdeutsch Nov 12, 2026
124a9a7
Write Android's scraped title and icon over the push as it is at the …
cdeutsch Nov 12, 2026
5a194d8
Rewrite an Apple push only for something new, over the push as it is …
cdeutsch Nov 12, 2026
2f819e0
Give a scrape's icon requests a time budget and stop them when the jo…
cdeutsch Nov 12, 2026
6f7bca8
Look up web file icons in a Map so a file's name cannot reach Object'…
cdeutsch Nov 12, 2026
316e2e5
Tell Android users that links from before the update show a plain icon
cdeutsch Nov 12, 2026
bcdb549
Draw a page icon on every client only when it is a PNG within the spe…
cdeutsch Nov 12, 2026
4cbc000
Remember a web icon that would not decode by the icon, not by the push
cdeutsch Nov 12, 2026
bca6fbd
Say once in the spec where a file push's icon comes from
cdeutsch Nov 12, 2026
472aafa
Say in the spec that a scrape is written over the push as it is at th…
cdeutsch Nov 12, 2026
87f49b9
Bound each Apple scrape request start to finish with a resource timeout
cdeutsch Nov 12, 2026
bacc51a
Bound the web's page icon in bytes, as the phones do
cdeutsch Nov 12, 2026
9a0ccaa
Compare Android's scraped title with the stored one after cutting it
cdeutsch Nov 12, 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
1 change: 0 additions & 1 deletion apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ dependencies {
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-storage")
implementation("com.google.firebase:firebase-messaging")
implementation("com.github.bumptech.glide:glide:5.0.7")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0")

// Used directly by our own code and layouts. These arrive transitively via
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.makerocketgonow.pushthat

import androidx.annotation.DrawableRes

/**
* The icon a row shows for a file type, shipped inside the app. These were loaded from
* `https://pushthat.link/files/<ext>.png`, which put the type of every encrypted file a list
* showed into the site's logs (docs/e2ee.md, Icons). The PNGs are copies of
* `apps/web/public/files`, with a `file_icon_` prefix so they cannot clash with the app's
* other drawables (and so a type like `7z` would still make a valid resource name).
*
* The one list of the types that have an icon: adding one is a `file_icon_<ext>.png` in
* `res/drawable-nodpi` (one size, never scaled per density, as the web serves it) and a line
* here. A map of ids rather than a lookup by name, which would hide the icons from the
* compiler and from resource shrinking.
*/
object FileIcons {
private val BY_EXT = mapOf(
"aac" to R.drawable.file_icon_aac,
"ai" to R.drawable.file_icon_ai,
"apk" to R.drawable.file_icon_apk,
"avi" to R.drawable.file_icon_avi,
"css" to R.drawable.file_icon_css,
"csv" to R.drawable.file_icon_csv,
"doc" to R.drawable.file_icon_doc,
"flac" to R.drawable.file_icon_flac,
"gif" to R.drawable.file_icon_gif,
"html" to R.drawable.file_icon_html,
"indd" to R.drawable.file_icon_indd,
"jpeg" to R.drawable.file_icon_jpeg,
"jpg" to R.drawable.file_icon_jpg,
"js" to R.drawable.file_icon_js,
"mov" to R.drawable.file_icon_mov,
"mp3" to R.drawable.file_icon_mp3,
"mp4" to R.drawable.file_icon_mp4,
"pdf" to R.drawable.file_icon_pdf,
"png" to R.drawable.file_icon_png,
"pproj" to R.drawable.file_icon_pproj,
"psd" to R.drawable.file_icon_psd,
"tiff" to R.drawable.file_icon_tiff,
"txt" to R.drawable.file_icon_txt,
"xls" to R.drawable.file_icon_xls,
"xml" to R.drawable.file_icon_xml,
"zip" to R.drawable.file_icon_zip,
)

/** The icon for files ending in [ext], any case, or null for a type with none. */
@DrawableRes
fun forExt(ext: String?): Int? = ext?.lowercase()?.let { BY_EXT[it] }
}
Original file line number Diff line number Diff line change
Expand Up @@ -384,12 +384,12 @@ class MainActivity : AppCompatActivity() {
return when (val read = PushDocument.read(keys, doc.id, data)) {
null -> null
is PushDocument.Read.Plain ->
PushItem(doc.id, read.title.ifBlank { read.url }, createdAt, read.url, read.ext, PushTarget.Link(read.url))
PushItem(doc.id, read.title.ifBlank { read.url }, createdAt, null, read.ext, PushTarget.Link(read.url))
PushDocument.Read.Locked ->
PushItem(doc.id, getString(R.string.locked_push), createdAt, null, null, PushTarget.Locked)
is PushDocument.Read.Opened -> when (val content = read.content) {
is PushContent.Url ->
PushItem(doc.id, content.title.ifBlank { content.url }, createdAt, content.url, content.ext, PushTarget.Link(content.url))
PushItem(doc.id, content.title.ifBlank { content.url }, createdAt, content.icon, content.ext, PushTarget.Link(content.url))
is PushContent.File -> {
val target = doc.getString("objectPath")?.let { PushTarget.File(it, content) } ?: PushTarget.Locked
PushItem(doc.id, content.name, createdAt, null, content.ext, target)
Expand Down Expand Up @@ -857,8 +857,11 @@ data class PushItem(
val id: String,
val title: String,
val createdAt: Date?,
/** The URL the favicon comes from; none for an encrypted file or a locked push. */
val iconUrl: String?,
/**
* The page's icon as the envelope carries it (base64 PNG), or null or empty for none: a
* file push, a locked push, a plaintext push and a link whose page gave none.
*/
val icon: String?,
val ext: String?,
val target: PushTarget,
)
Expand Down Expand Up @@ -913,8 +916,8 @@ class PushAdapter(
private val dateFormat = SimpleDateFormat("h:mm a", Locale.getDefault())

fun bind(push: PushItem) {
// Load favicon or file icon
avatarImage.loadFavicon(push.iconUrl, push.ext)
// From the push itself: drawing a row fetches nothing.
avatarImage.showIcon(push.icon, push.ext)

// Set title
titleText.text = push.title
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.makerocketgonow.pushthat

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.makerocketgonow.pushthat.e2ee.E2ee
import okhttp3.HttpUrl
import org.jsoup.nodes.Document
import java.io.ByteArrayOutputStream
import kotlin.math.max
import kotlin.math.roundToInt

/**
* Finding and shrinking a page's icon, for the envelope's `icon` (docs/e2ee.md, Icons).
* `TitleScrapingWorker` does the fetching; what is here is the choice of candidates and the
* sizes, kept out of the worker so the JVM tests cover them, and the bitmap work, which
* they cannot run.
*
* The icon travels in the envelope so that nothing is fetched to draw a row. The list used
* to ask Google's favicon service for every link's host each time it was drawn, which told
* Google, in plaintext, the host of every push the envelope keeps from it.
*/
object PageIcons {
/** The longest side of an icon in the envelope, in pixels. */
const val MAX_SIDE = 64

/** Tried once when the icon at [MAX_SIDE] encodes past [MAX_ICON_BYTES]. */
const val FALLBACK_SIDE = 32

/** The most PNG bytes an icon may have (5464 characters of base64). */
const val MAX_ICON_BYTES = 4096

/** How much of each candidate is read: an icon larger than this is skipped. */
const val MAX_FETCH_BYTES = 256L * 1024

/**
* How many of the page's `<link>` candidates are tried. The spec says every one; a page
* listing hundreds of broken links would otherwise hold the worker for as many requests.
* Sites list several sizes of the same icon, all of which decode, so the first few
* almost always settle it, and `/favicon.ico` is tried after these whatever the page has.
*/
const val MAX_LINK_CANDIDATES = 8

private val APPLE_TOUCH_ICON = setOf("apple-touch-icon", "apple-touch-icon-precomposed")

/**
* Where to look for [doc]'s icon, in the order to try: every `<link>` whose `rel` names
* `apple-touch-icon` or `apple-touch-icon-precomposed`, then every one naming `icon`
* (`shortcut icon` included) that is not SVG by its `type` or its path, then
* `/favicon.ico` at the page's origin. Each `href` is resolved against [pageUrl], the
* page's final URL after redirects: not against the address the push names, which a
* redirect may have left, nor a `<base>` element, which the spec does not follow. Only
* http and https come back (`HttpUrl` cannot hold anything else), each once.
*
* SVG is left out because `BitmapFactory` cannot open it, so fetching one only costs a
* request. The spec filters the `icon` group only: an apple-touch-icon is a PNG by
* Apple's definition, and one that is not simply fails to decode.
*/
fun candidates(doc: Document, pageUrl: HttpUrl): List<HttpUrl> {
// An empty href resolves to the page itself, which is HTML, not an icon.
val links = doc.select("link[rel][href]").filter { it.attr("href").isNotBlank() }.map { link ->
val rel = link.attr("rel").lowercase().split(Regex("\\s+")).toSet()
Triple(rel, link.attr("type").trim().lowercase(), pageUrl.resolve(link.attr("href").trim()))
}
val touch = links.filter { (rel, _, _) -> rel.any { it in APPLE_TOUCH_ICON } }.mapNotNull { it.third }
val icons = links
.filter { (rel, type, url) -> "icon" in rel && type != "image/svg+xml" && url != null && !isSvgPath(url) }
.mapNotNull { it.third }
val fromLinks = (touch + icons).distinct().take(MAX_LINK_CANDIDATES)
return (fromLinks + listOfNotNull(pageUrl.resolve("/favicon.ico"))).distinct()
}

private fun isSvgPath(url: HttpUrl) = url.encodedPath.lowercase().endsWith(".svg")

/**
* [width] by [height] scaled down to fit [side] by [side] with its aspect kept, never
* up, and never below one pixel a side (a 1000 by 1 strip would round to nothing).
*/
fun fit(width: Int, height: Int, side: Int): Pair<Int, Int> {
val longest = max(width, height)
if (longest <= side) return width to height
val scale = side.toDouble() / longest
return max(1, (width * scale).roundToInt()) to max(1, (height * scale).roundToInt())
}

/**
* The `inSampleSize` that decodes [width] by [height] as small as it can while its
* longest side stays at least [side]. A 256 KiB PNG of one color can be tens of
* thousands of pixels a side, which decoded whole would be gigabytes; sampled, it is
* never more than four times the icon's area, and the scale after it does the rest.
*/
fun sampleSize(width: Int, height: Int, side: Int): Int {
val longest = max(width, height)
var sample = 1
while (longest / (sample * 2) >= side) sample *= 2
return sample
}

/** [bytes] as an image, or null when the platform's decoder cannot open them. */
fun decode(bytes: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, MAX_SIDE)
}
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}

/**
* An envelope's `icon` for a row, or null when it is not what the spec says one is: a
* PNG of at most [MAX_SIDE] a side and [MAX_ICON_BYTES], checked before decoding. Every
* client draws exactly that and nothing else (docs/e2ee.md, Icons), so an icon some
* other writer got wrong is passed over the same way everywhere rather than drawing on
* one platform and not another, and a PNG claiming to be huge is never decoded.
*/
fun decodeForDisplay(bytes: ByteArray): Bitmap? {
if (bytes.size > MAX_ICON_BYTES) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outMimeType != "image/png") return null
if (bounds.outWidth !in 1..MAX_SIDE || bounds.outHeight !in 1..MAX_SIDE) return null
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}

/**
* [bitmap] as the envelope's `icon`: fit to [MAX_SIDE], PNG, base64; at [FALLBACK_SIDE]
* when that is over [MAX_ICON_BYTES]; null when that is too. Null is no icon, not a
* reason to try the next candidate: the decoder opened this one, and the spec takes the
* first it opens.
*/
fun encode(bitmap: Bitmap): String? {
for (side in listOf(MAX_SIDE, FALLBACK_SIDE)) {
val png = png(bitmap, side)
if (png.size <= MAX_ICON_BYTES) return E2ee.base64(png)
}
return null
}

private fun png(bitmap: Bitmap, side: Int): ByteArray {
val (width, height) = fit(bitmap.width, bitmap.height, side)
val scaled = if (width == bitmap.width && height == bitmap.height) {
bitmap
} else {
Bitmap.createScaledBitmap(bitmap, width, height, true)
}
try {
return ByteArrayOutputStream().also { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray()
} finally {
if (scaled !== bitmap) scaled.recycle()
}
}
}
Loading
Loading