diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b33696a..5e5f278 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -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 diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt new file mode 100644 index 0000000..e78962c --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/FileIcons.kt @@ -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/.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_.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] } +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt index 95e3ad8..d24ee80 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt @@ -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) @@ -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, ) @@ -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 diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt new file mode 100644 index 0000000..7db7c79 --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/PageIcons.kt @@ -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 `` 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 `` 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 `` 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 { + // 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 { + 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() + } + } +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt index ef479bf..270bd13 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/ShareActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.SystemClock import android.util.Log import android.view.animation.Animation import android.view.animation.RotateAnimation @@ -13,6 +14,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth +import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.FirebaseFirestoreException import com.google.firebase.firestore.Source import com.google.firebase.firestore.firestore @@ -31,11 +33,14 @@ import com.makerocketgonow.pushthat.e2ee.PushKeys import com.makerocketgonow.pushthat.e2ee.PushStore import com.makerocketgonow.pushthat.e2ee.PushTooLargeException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.tasks.await import androidx.lifecycle.lifecycleScope import androidx.work.* +import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.jsoup.Jsoup @@ -533,6 +538,10 @@ class ShareActivity : AppCompatActivity() { * this install holds; another device scraping the same push writes the same title, and * the last write wins. A plaintext push (a job queued before this version) gets its title * alone, as before; the migration encrypts it. + * + * An encrypted push that has no icon also gets the page's (docs/e2ee.md, Icons), looked for + * in the page this pass already read, so the list never has to ask anyone for it. A push + * that has one keeps it and is not looked up again. */ class TitleScrapingWorker( context: Context, @@ -556,14 +565,15 @@ class TitleScrapingWorker( val updates: Map = when (val push = PushDocument.read(keys, documentId, data)) { is PushDocument.Read.Opened -> { val content = push.content as? PushContent.Url ?: return Result.success() - val title = scrapeTitle(content.url) - if (title.isNullOrBlank() || title == content.title) return Result.success() - val prepared = PreparedPush.prepare(keys, documentId, content.copy(title = title)) - mapOf("enc" to prepared.enc.toMap(), "searchTokens" to prepared.searchTokens) + val page = scrape(content.url, withIcon = content.icon.isEmpty()) ?: return Result.success() + writeScraped(ref, keys, documentId, page) + return Result.success() } is PushDocument.Read.Plain -> { if (push.type != PushContent.TYPE_URL) return Result.success() - val title = scrapeTitle(push.url) + // No icon: a plaintext push has no envelope to carry one, and the + // migration that gives it one does not scrape. + val title = scrape(push.url, withIcon = false)?.title if (title.isNullOrBlank() || title == push.title) return Result.success() mapOf("title" to PreparedPush.cutTitle(title)) } @@ -575,28 +585,61 @@ class TitleScrapingWorker( Log.e(TAG, "Error saving scraped title", e) // A refusal is final: the push is gone or is no longer this account's to change. if (e.code == FirebaseFirestoreException.Code.PERMISSION_DENIED) Result.failure() else Result.retry() - } catch (e: PushTooLargeException) { - // The URL with the scraped title does not fit `enc.ct`'s bound, and the page will - // give the same title next time: the push keeps the title it has. Retried, as the - // catch below would, it ran for as long as WorkManager kept the job. - Log.w(TAG, "The scraped title does not fit the push") - Result.success() } catch (e: Exception) { Log.e(TAG, "Error saving scraped title", e) Result.retry() } } - private suspend fun scrapeTitle(url: String): String? { + /** What a scrape found: either may be null. */ + private class Scraped(val title: String?, val icon: String?) + + /** + * Writes what [page] found over the push as it is now, not as it was read before the + * scrape. The scrape takes seconds, and another device scraping the same push may write + * meanwhile. With titles alone that did no harm, since both wrote the same title; with + * icons the writes differ (one device's decoder can open an icon another's refuses), and + * a title-only write landing after a title-and-icon one dropped the icon for good, since + * a titled push is not scraped again. Re-read in a transaction, `retitle` keeps an icon + * the push gained meanwhile and writes nothing when nothing is new. + */ + private suspend fun writeScraped(ref: DocumentReference, keys: PushKeys, id: String, page: Scraped) { + db.runTransaction { tx -> + val data = tx.get(ref).data ?: return@runTransaction null + val fresh = (PushDocument.read(keys, id, data) as? PushDocument.Read.Opened)?.content as? PushContent.Url + ?: return@runTransaction null + val prepared = try { + PushDocument.retitle(keys, id, fresh, page.title, page.icon) + } catch (e: PushTooLargeException) { + // The URL with the scraped title does not fit `enc.ct`'s bound, and the page + // will give the same title next time: the push keeps the title it has. Left + // to `doWork`'s catch-all it was retried for as long as WorkManager kept the + // job, and thrown out of a transaction it would reach it as Firestore's. + Log.w(TAG, "The scraped title does not fit the push") + null + } ?: return@runTransaction null + tx.update(ref, mapOf("enc" to prepared.enc.toMap(), "searchTokens" to prepared.searchTokens)) + null + }.await() + } + + /** + * [url]'s title and, when [withIcon], its icon as the envelope's `icon`. Null when there + * is no HTML page to read (an error, a file, a URL OkHttp refuses). + */ + private suspend fun scrape(url: String, withIcon: Boolean): Scraped? { return withContext(Dispatchers.IO) { + // `callTimeout` bounds a whole request, where the default timeouts bound only a + // gap between bytes: a server sending one byte every few seconds held a request + // open indefinitely, and the icon adds up to nine requests to the page's. + val client = OkHttpClient.Builder().callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS).build() try { - val client = OkHttpClient() val request = Request.Builder() .url(url) - .header("User-Agent", "Mozilla/5.0") + .header("User-Agent", USER_AGENT) .build() - client.newCall(request).execute().use { response -> + val (doc, finalUrl) = client.newCall(request).execute().use { response -> if (!response.isSuccessful) { Log.e(TAG, "HTTP request failed: ${response.code}") return@withContext null @@ -606,7 +649,8 @@ class TitleScrapingWorker( // archives), which have no title to find, and the worker runs for received // ones too. Only an HTML page is read, and only its first 256 KiB, where // the is: `string()` pulled a whole file into memory. iOS and - // macOS read the same. + // macOS read the same. No page means no icon either: a link to a file + // has no <link rel=icon> to look in, and its row shows the file's type. val body = response.body val type = body.contentType() if (type == null || !type.subtype.lowercase().contains("html")) return@withContext null @@ -615,10 +659,13 @@ class TitleScrapingWorker( val bytes = source.buffer.readByteArray(minOf(source.buffer.size, MAX_PAGE_BYTES)) // The header's charset, or, when it names none, whatever the page's // <meta> says. The cap can cut the last character in two, well past the - // <title>, so that costs nothing. - val doc = Jsoup.parse(ByteArrayInputStream(bytes), type.charset()?.name(), url) - doc.title().takeIf { it.isNotBlank() } + // <title>, so that costs nothing. The base is the final URL, after + // redirects, which is what the icon's links are relative to. + val finalUrl = response.request.url + Jsoup.parse(ByteArrayInputStream(bytes), type.charset()?.name(), finalUrl.toString()) to finalUrl } + val icon = if (withIcon) fetchIcon(client, PageIcons.candidates(doc, finalUrl)) else null + Scraped(doc.title().takeIf { it.isNotBlank() }, icon) } catch (e: IOException) { // The exception's type only: its message can name the page's host. Log.e(TAG, "Error scraping title: ${e.javaClass.simpleName}") @@ -630,12 +677,57 @@ class TitleScrapingWorker( } } + /** + * The first of [candidates] that `BitmapFactory` opens, as the envelope's `icon`, or + * null. A candidate that fails in any way is passed over: an icon is a nicety, and + * nothing about it may cost the push its title. Logged by the exception's type only, + * since its message can name the host. + */ + private suspend fun fetchIcon(client: OkHttpClient, candidates: List<HttpUrl>): String? { + // Stopped between candidates when WorkManager stops the worker (the network went, or + // its time ran out), and after a budget for the lot: the requests block, so without + // these checks the loop ran on through every candidate on an IO thread regardless. + val deadline = SystemClock.elapsedRealtime() + ICON_BUDGET_MS + for (candidate in candidates) { + currentCoroutineContext().ensureActive() + if (SystemClock.elapsedRealtime() > deadline) return null + try { + val request = Request.Builder().url(candidate).header("User-Agent", USER_AGENT).build() + val bytes = client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + val source = response.body.source() + // One byte past the cap tells a file that fits from one that does not. + // A cut image would decode partly or not at all, so it is skipped. + source.request(PageIcons.MAX_FETCH_BYTES + 1) + if (source.buffer.size > PageIcons.MAX_FETCH_BYTES) null else source.buffer.readByteArray() + } ?: continue + val bitmap = PageIcons.decode(bytes) ?: continue + try { + return PageIcons.encode(bitmap) + } finally { + bitmap.recycle() + } + } catch (e: Exception) { + Log.w(TAG, "Could not fetch an icon: ${e.javaClass.simpleName}") + } + } + return null + } + companion object { private const val KEY_DOCUMENT_ID = "documentId" /** How much of a page the title is looked for in. */ private const val MAX_PAGE_BYTES = 256L * 1024 + private const val USER_AGENT = "Mozilla/5.0" + + /** The longest any one request of a scrape may take, start to finish. */ + private const val CALL_TIMEOUT_SECONDS = 15L + + /** How long the icon candidates are tried for, after the page. */ + private const val ICON_BUDGET_MS = 30_000L + fun enqueue(context: Context, documentId: String) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt index 1fc908f..e26ab67 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt @@ -1,10 +1,10 @@ package com.makerocketgonow.pushthat import android.content.Context +import android.graphics.Bitmap import android.graphics.Camera -import android.net.Uri import android.util.AttributeSet -import android.util.Log +import android.util.LruCache import android.view.View import android.view.animation.Animation import android.view.animation.Transformation @@ -12,8 +12,7 @@ import android.widget.Checkable import android.widget.ImageView import android.widget.ViewFlipper import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.Target +import com.makerocketgonow.pushthat.e2ee.E2ee class SwivelCheckView @JvmOverloads constructor( context: Context, @@ -35,6 +34,8 @@ class SwivelCheckView @JvmOverloads constructor( View.inflate(context, R.layout.merge_swivel_check, this) frontImageView = getChildAt(0) as ImageView backImageView = getChildAt(1) as ImageView + backImageView.setImageResource(R.drawable.ic_check_circle) + backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) } fun setChecked(targetState: Boolean, shouldAnimate: Boolean = true) { @@ -71,51 +72,22 @@ class SwivelCheckView @JvmOverloads constructor( override fun toggle() = setChecked(!isChecked) /** - * The icon for [fileExt] when there is one, else [url]'s favicon. A push with neither - * (an encrypted file of an unknown type, a locked push) gets the placeholder: there is - * no host to ask for, and a locked push's URL is not known at all. + * The row's icon, from what the push carries and nothing else: nothing is fetched to + * draw a row (docs/e2ee.md, Icons). [pageIcon] is a URL push's `icon`, drawn when it + * decodes; else the app's own icon for [fileExt], when it has one (a file push, or a + * link to a file, which has no page to take an icon from); else the placeholder, which + * is also what a locked push draws. + * + * The page's icon wins over the type: a link whose path ends in `.html` is a page, and + * its own icon says more than the type's. */ - fun loadFavicon(url: String?, fileExt: String? = null) { - // Check if there's a file extension and it matches our supported icons - if (!fileExt.isNullOrBlank()) { - Log.d("SwivelCheckView", "File extension is not null or blank") - val iconName = SUPPORTED_FILE_ICONS.find { it == "${fileExt.lowercase()}.png" } - if (iconName != null) { - // Load file type icon from PushThat CDN - Glide.with(context) - .load("https://pushthat.link/files/${iconName}") - .fitCenter() - .override(Target.SIZE_ORIGINAL) - .error(R.drawable.default_favicon) - .into(frontImageView) - - // Load checkmark for back side - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) - return - } - } - - // If no file extension or unsupported, fall back to favicon - val host = url?.let { Uri.parse(it).host } - if (host.isNullOrEmpty()) { - Glide.with(context).clear(frontImageView) - frontImageView.setImageResource(R.drawable.default_favicon) - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) - return + fun showIcon(pageIcon: String?, fileExt: String? = null) { + val bitmap = pageIcon?.takeIf { it.isNotEmpty() }?.let { decodedIcon(it) } + if (bitmap != null) { + frontImageView.setImageBitmap(bitmap) + } else { + frontImageView.setImageResource(FileIcons.forExt(fileExt) ?: R.drawable.default_favicon) } - val faviconUrl = "https://www.google.com/s2/favicons?domain=${Uri.encode(host)}&sz=256" - - Glide.with(context) - .load(faviconUrl) - .fitCenter() - .override(Target.SIZE_ORIGINAL) - .error(R.drawable.default_favicon) - .into(frontImageView) - - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) } fun setOnCheckedChangeListener(listener: (SwivelCheckView, Boolean) -> Unit) { @@ -123,13 +95,30 @@ class SwivelCheckView @JvmOverloads constructor( } companion object { - private val SUPPORTED_FILE_ICONS = listOf( - "pproj.png", "zip.png", "indd.png", "csv.png", "css.png", "pdf.png", - "aac.png", "png.png", "xls.png", "avi.png", "txt.png", "mp3.png", - "tiff.png", "psd.png", "jpg.png", "jpeg.png", "mov.png", "html.png", - "ai.png", "js.png", "flac.png", "mp4.png", "gif.png", "doc.png", - "xml.png", "apk.png" - ) + /** + * Page icons already decoded, by their base64. Decoding one is cheap (a PNG of at + * most 64 by 64 and 4 KB), but a bind happens on every scroll, so each is decoded once + * on the main thread rather than per bind or through a loader thread. Keyed by the + * string rather than the push, so a push whose icon changes is never drawn with the + * old one. 256 icons of at most 16 KB each is about 4 MB. + */ + private val decodedIcons = LruCache<String, Bitmap>(256) + + /** Icons that would not draw, remembered so a bind does not decode them again. */ + private val refusedIcons = LruCache<String, Unit>(64) + + private fun decodedIcon(icon: String): Bitmap? { + decodedIcons.get(icon)?.let { return it } + if (refusedIcons.get(icon) != null) return null + val bytes = try { + E2ee.fromBase64(icon) + } catch (e: IllegalArgumentException) { + null + } + val bitmap = bytes?.let { PageIcons.decodeForDisplay(it) } + if (bitmap == null) refusedIcons.put(icon, Unit) else decodedIcons.put(icon, bitmap) + return bitmap + } private fun toChecked(calculateCenterX: () -> Float, updateDisplayedSide: (DisplaySide) -> Unit) = createAnimation(0f..180f, calculateCenterX, updateDisplayedSide) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt index 1b25908..b331b3e 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/PushDocument.kt @@ -18,11 +18,21 @@ sealed class PushContent { abstract fun toJson(): ByteArray - data class Url(val url: String, val title: String, val ext: String) : PushContent() { + /** + * A link. [icon] is the page's icon as base64 PNG (docs/e2ee.md, Icons), or empty for + * none: a push from before icons, a page with no icon this device could decode, or one + * whose icon would not fit beside its URL and title. + */ + data class Url(val url: String, val title: String, val ext: String, val icon: String = "") : PushContent() { override val type get() = TYPE_URL override val searchText get() = "$title $url" - override fun toJson(): ByteArray = - E2ee.utf8(JSONObject().put("url", url).put("title", title).put("ext", ext).toString()) + override fun toJson(): ByteArray { + val json = JSONObject().put("url", url).put("title", title).put("ext", ext) + // Left out rather than written empty: the spec reads the two alike, and an icon-less + // push then encrypts the same bytes it did before icons, which vector 3 pins. + if (icon.isNotEmpty()) json.put("icon", icon) + return E2ee.utf8(json.toString()) + } } /** @@ -65,7 +75,14 @@ sealed class PushContent { try { val obj = JSONObject(String(json, Charsets.UTF_8)) return when (type) { - TYPE_URL -> Url(obj.getString("url"), obj.optString("title", ""), obj.optString("ext", "")) + // `icon` is optional: a push from before icons, or from a client that found + // none, has no such key. + TYPE_URL -> Url( + obj.getString("url"), + obj.optString("title", ""), + obj.optString("ext", ""), + obj.optString("icon", ""), + ) TYPE_FILE -> { val legacyPlain = obj.optBoolean("legacyPlain", false) val fk = if (legacyPlain) null else E2ee.fromBase64(obj.getString("fk")) @@ -257,6 +274,37 @@ object PushDocument { } } + /** + * The rewrite of URL push [id] after its page was scraped (docs/e2ee.md, Titles and + * Icons), or null when there is nothing to write. [title] and [icon] are what the scrape + * found, either of them null for nothing. + * + * A rewrite happens when the title changed or an icon was found for a push that had + * none. A push that has an icon keeps it: the worker does not look for one then, and a + * scrape that finds none must not take it away. When the envelope with an icon would + * not fit `enc.ct`'s bound, the title is written alone, since the title is what the row + * is read by; with no new title, that leaves nothing to write. Throws + * [PushTooLargeException] when even the title alone does not fit, as before icons. + */ + fun retitle(keys: PushKeys, id: String, content: PushContent.Url, title: String?, icon: String?): PreparedPush? { + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + val newTitle = title?.let { PreparedPush.cutTitle(it) }?.takeIf { it.isNotBlank() && it != content.title } + val newIcon = icon?.takeIf { it.isNotEmpty() && content.icon.isEmpty() } + if (newTitle == null && newIcon == null) return null + val updated = content.copy(title = newTitle ?: content.title, icon = newIcon ?: content.icon) + return try { + PreparedPush.prepare(keys, id, updated) + } catch (e: PushTooLargeException) { + when { + newTitle == null -> null + updated.icon.isEmpty() -> throw e + // The icon goes even when it was the push's own: the URL and new title did + // not fit beside it, and the title is worth more than the picture. + else -> PreparedPush.prepare(keys, id, updated.copy(icon = "")) + } + } + } + /** The fields an encrypted push keeps; a migration deletes every other one. */ private val ENCRYPTED_FIELDS = setOf( "userId", "type", "createdAt", "expiresAt", "deviceId", "keyVersion", "enc", "searchTokens", "objectPath", diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_aac.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_aac.png new file mode 100644 index 0000000..229acde Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_aac.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_ai.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_ai.png new file mode 100644 index 0000000..bc85e0f Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_ai.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_apk.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_apk.png new file mode 100644 index 0000000..e8092db Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_apk.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_avi.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_avi.png new file mode 100644 index 0000000..08b8557 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_avi.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_css.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_css.png new file mode 100644 index 0000000..56bca64 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_css.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_csv.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_csv.png new file mode 100644 index 0000000..551320d Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_csv.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_doc.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_doc.png new file mode 100644 index 0000000..ae4112e Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_doc.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_flac.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_flac.png new file mode 100644 index 0000000..1fa3e81 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_flac.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_gif.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_gif.png new file mode 100644 index 0000000..bdbe2cd Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_gif.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_html.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_html.png new file mode 100644 index 0000000..aa4b93f Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_html.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_indd.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_indd.png new file mode 100644 index 0000000..1c1acd8 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_indd.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpeg.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpeg.png new file mode 100644 index 0000000..9345579 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpeg.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpg.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpg.png new file mode 100644 index 0000000..9345579 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_jpg.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_js.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_js.png new file mode 100644 index 0000000..6fca286 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_js.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_mov.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mov.png new file mode 100644 index 0000000..dd413f7 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mov.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp3.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp3.png new file mode 100644 index 0000000..c1ce20e Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp3.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp4.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp4.png new file mode 100644 index 0000000..9d64a53 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_mp4.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_pdf.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_pdf.png new file mode 100644 index 0000000..95ab6c6 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_pdf.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_png.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_png.png new file mode 100644 index 0000000..9e5fad2 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_png.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_pproj.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_pproj.png new file mode 100644 index 0000000..6308b56 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_pproj.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_psd.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_psd.png new file mode 100644 index 0000000..d44694a Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_psd.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_tiff.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_tiff.png new file mode 100644 index 0000000..16f4bb4 Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_tiff.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_txt.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_txt.png new file mode 100644 index 0000000..9215e1a Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_txt.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_xls.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_xls.png new file mode 100644 index 0000000..a3a43ea Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_xls.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_xml.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_xml.png new file mode 100644 index 0000000..777f56d Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_xml.png differ diff --git a/apps/android/app/src/main/res/drawable-nodpi/file_icon_zip.png b/apps/android/app/src/main/res/drawable-nodpi/file_icon_zip.png new file mode 100644 index 0000000..587554a Binary files /dev/null and b/apps/android/app/src/main/res/drawable-nodpi/file_icon_zip.png differ diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt new file mode 100644 index 0000000..284ebb0 --- /dev/null +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/PageIconsTest.kt @@ -0,0 +1,121 @@ +package com.makerocketgonow.pushthat + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Which icons a page offers, in what order, and the sizes they are cut to. Every page is made up. */ +class PageIconsTest { + + private fun candidates(head: String, pageUrl: String = "https://www.example.com/blog/post/"): List<String> = + PageIcons.candidates(Jsoup.parse("<html><head>$head</head><body></body></html>"), pageUrl.toHttpUrl()) + .map { it.toString() } + + @Test + fun touchIconsComeFirstThenIconsThenTheFavicon() { + val found = candidates( + """ + <link rel="icon" href="/i.png"> + <link rel="apple-touch-icon" href="touch.png"> + <link rel="shortcut icon" href="//cdn.example.net/s.ico"> + <link rel="apple-touch-icon-precomposed" href="https://example.org/p.png"> + <link rel="stylesheet" href="/style.css"> + """, + ) + assertEquals( + listOf( + "https://www.example.com/blog/post/touch.png", + "https://example.org/p.png", + "https://www.example.com/i.png", + "https://cdn.example.net/s.ico", + "https://www.example.com/favicon.ico", + ), + found, + ) + } + + @Test + fun relIsAListOfTokensInAnyCase() { + val found = candidates("""<link rel="Shortcut ICON" href="a.png"><link rel="mask-icon" href="m.png">""") + assertEquals(listOf("https://www.example.com/blog/post/a.png", "https://www.example.com/favicon.ico"), found) + } + + @Test + fun svgIconsAreLeftOutByTypeOrPath() { + val found = candidates( + """ + <link rel="icon" type="image/svg+xml" href="/by-type"> + <link rel="icon" href="/logo.SVG?v=2"> + <link rel="icon" type="image/png" href="/ok.png"> + <link rel="apple-touch-icon" href="/touch.svg"> + """, + ) + assertEquals( + listOf( + "https://www.example.com/touch.svg", + "https://www.example.com/ok.png", + "https://www.example.com/favicon.ico", + ), + found, + ) + } + + @Test + fun hrefsResolveAgainstTheFinalUrlNotABaseElement() { + val found = candidates( + """<base href="https://elsewhere.example/"><link rel="icon" href="../img/a.png">""", + pageUrl = "http://example.com:8080/blog/post/page.html?q=1", + ) + assertEquals(listOf("http://example.com:8080/blog/img/a.png", "http://example.com:8080/favicon.ico"), found) + } + + @Test + fun onlyHttpAndHttpsAreFetchedAndEachOnce() { + val found = candidates( + """ + <link rel="icon" href="data:image/png;base64,AAAA"> + <link rel="icon" href="javascript:void(0)"> + <link rel="icon" href="ftp://example.com/i.png"> + <link rel="icon" href=""> + <link rel="icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/t.png"> + <link rel="icon" href="https://www.example.com/t.png"> + """, + ) + assertEquals(listOf("https://www.example.com/t.png", "https://www.example.com/favicon.ico"), found) + } + + @Test + fun aPageWithoutLinksStillHasItsFavicon() { + assertEquals(listOf("https://example.com/favicon.ico"), candidates("", pageUrl = "https://example.com")) + } + + @Test + fun manyLinksAreCutButTheFaviconStays() { + val head = (1..20).joinToString("") { """<link rel="icon" href="/i$it.png">""" } + val found = candidates(head) + assertEquals(PageIcons.MAX_LINK_CANDIDATES + 1, found.size) + assertEquals("https://www.example.com/i1.png", found.first()) + assertEquals("https://www.example.com/favicon.ico", found.last()) + } + + @Test + fun anIconIsScaledDownToFitKeepingItsAspectAndNeverUp() { + assertEquals(64 to 64, PageIcons.fit(180, 180, 64)) + assertEquals(64 to 32, PageIcons.fit(200, 100, 64)) + assertEquals(11 to 32, PageIcons.fit(100, 300, 32)) + assertEquals(16 to 16, PageIcons.fit(16, 16, 64)) + assertEquals(64 to 64, PageIcons.fit(64, 64, 64)) + assertEquals(64 to 1, PageIcons.fit(1000, 1, 64)) + } + + @Test + fun aLargeImageIsSampledButNeverBelowTheIconsSize() { + assertEquals(1, PageIcons.sampleSize(64, 64, 64)) + assertEquals(1, PageIcons.sampleSize(127, 20, 64)) + assertEquals(2, PageIcons.sampleSize(128, 128, 64)) + assertEquals(2, PageIcons.sampleSize(200, 200, 64)) + assertEquals(256, PageIcons.sampleSize(20_000, 10, 64)) + } +} diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt index 7623606..e9a6381 100644 --- a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/e2ee/PushDocumentTest.kt @@ -128,6 +128,78 @@ class PushDocumentTest { } } + // ------------------------------------------------------------------------------------- + // Icons + + /** Valid base64 of the right length for a full-size icon; the envelope never decodes it. */ + private val bigIcon = "A".repeat(5464) + + @Test + fun anIconTravelsInTheEnvelope() { + val content = PushContent.Url("https://example.com", "Example", "", icon = "iVBORw0KGgo=") + val fields = PreparedPush.prepare(keys, "push-11", content).fields() + assertEquals(PushDocument.Read.Opened(content), PushDocument.read(keys, "push-11", fields)) + } + + @Test + fun noIconWritesTheSameJsonAsBeforeIcons() { + val json = String(PushContent.Url("https://example.com", "Example", "").toJson(), Charsets.UTF_8) + assertEquals(setOf("url", "title", "ext"), org.json.JSONObject(json).keys().asSequence().toSet()) + } + + @Test + fun anEnvelopeFromBeforeIconsOrWithAnEmptyOneHasNone() { + val old = """{"url":"https://example.com","title":"Example","ext":""}""".toByteArray() + assertEquals("", (PushContent.parse("url", old) as PushContent.Url).icon) + val empty = """{"url":"https://example.com","title":"Example","ext":"","icon":""}""".toByteArray() + assertEquals("", (PushContent.parse("url", empty) as PushContent.Url).icon) + } + + private fun opened(prepared: PreparedPush?, id: String): PushContent.Url = + (PushDocument.read(keys, id, prepared!!.fields()) as PushDocument.Read.Opened).content as PushContent.Url + + @Test + fun aRetitleHappensForANewTitleOrAFirstIcon() { + val untitled = PushContent.Url("https://example.com", "", "") + assertEquals(PushContent.Url("https://example.com", "Example", ""), opened(PushDocument.retitle(keys, "r1", untitled, "Example", null), "r1")) + assertEquals(PushContent.Url("https://example.com", "", "", "QUJD"), opened(PushDocument.retitle(keys, "r1", untitled, null, "QUJD"), "r1")) + + val titled = PushContent.Url("https://example.com", "Example", "") + assertNull(PushDocument.retitle(keys, "r2", titled, "Example", null)) + assertNull(PushDocument.retitle(keys, "r2", titled, " ", "")) + assertEquals(PushContent.Url("https://example.com", "Example", "", "QUJD"), opened(PushDocument.retitle(keys, "r2", titled, "Example", "QUJD"), "r2")) + + // A title over the limit is stored cut, and read again it is the same title. + val long = "x".repeat(PreparedPush.MAX_TITLE_LENGTH + 50) + val cut = PushContent.Url("https://example.com", PreparedPush.cutTitle(long), "") + assertNull(PushDocument.retitle(keys, "r2", cut, long, null)) + } + + @Test + fun aPushKeepsTheIconItHas() { + val withIcon = PushContent.Url("https://example.com", "Old", "", "QUJD") + // A scrape that finds none does not take it away. + assertEquals(withIcon.copy(title = "New"), opened(PushDocument.retitle(keys, "r3", withIcon, "New", null), "r3")) + // Nor is it replaced, and a different icon alone is no reason to rewrite. + assertEquals(withIcon.copy(title = "New"), opened(PushDocument.retitle(keys, "r3", withIcon, "New", "REVG"), "r3")) + assertNull(PushDocument.retitle(keys, "r3", withIcon, "Old", "REVG")) + } + + @Test + fun anIconThatDoesNotFitLeavesTheTitleAlone() { + // Fits with its title, not with a full-size icon as well. + val long = PushContent.Url("https://example.com/" + "a".repeat(8000), "", "") + assertEquals(long.copy(title = "Example"), opened(PushDocument.retitle(keys, "r4", long, "Example", bigIcon), "r4")) + // With no new title, there is nothing left to write. + assertNull(PushDocument.retitle(keys, "r4", long, null, bigIcon)) + assertNull(PushDocument.retitle(keys, "r4", long.copy(title = "Example"), "Example", bigIcon)) + // Even the push's own icon goes when the new title does not fit beside it. + assertEquals(long.copy(title = "Example"), opened(PushDocument.retitle(keys, "r4", long.copy(icon = bigIcon), "Example", null), "r4")) + + val absurd = PushContent.Url("https://example.com/" + "a".repeat(13_000), "", "") + assertThrows(PushTooLargeException::class.java) { PushDocument.retitle(keys, "r5", absurd, "Example", bigIcon) } + } + // ------------------------------------------------------------------------------------- // Reading diff --git a/apps/android/store/whatsnew/whatsnew-en-US b/apps/android/store/whatsnew/whatsnew-en-US index 145c21d..7d8d1ed 100644 --- a/apps/android/store/whatsnew/whatsnew-en-US +++ b/apps/android/store/whatsnew/whatsnew-en-US @@ -1,3 +1,3 @@ -Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in sets up your keys from your password and shows a recovery key. Save it, because it is the only way to read your pushes again if you forget your password. Change password and Show a new recovery key are in the menu. +Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in shows a recovery key. Save it: it is the only way back to your pushes if you forget your password. Change password and Show a new recovery key are in the menu. -Notifications and search work on encrypted pushes. A share that cannot be confirmed is kept and retried instead of dropped, and a long press copies a file push's name. +A link's icon now comes from its page and travels with the push, so your list never asks Google. Older links show a plain icon. Notifications and search work on encrypted pushes, and an unconfirmed share is retried, not dropped. diff --git a/apps/ios/PushThat/Models/PushItem.swift b/apps/ios/PushThat/Models/PushItem.swift index eb11503..4345991 100644 --- a/apps/ios/PushThat/Models/PushItem.swift +++ b/apps/ios/PushThat/Models/PushItem.swift @@ -20,6 +20,9 @@ struct PushItem: Identifiable, Equatable { let source: Source /// The push's `keyVersion`, which a retitle has to match. let keyVersion: Int? + /// An encrypted URL push's icon (`PushIcon`), nil when it has none. Kept as the + /// envelope holds it, so that a retitle writes it back unchanged. + let icon: String? enum PushType: String { case url @@ -77,7 +80,8 @@ struct PushItem: Identifiable, Equatable { type: PushType = .url, ext: String? = nil, source: Source = .plaintext, - keyVersion: Int? = nil + keyVersion: Int? = nil, + icon: String? = nil ) { self.id = id self.url = url @@ -88,6 +92,7 @@ struct PushItem: Identifiable, Equatable { self.ext = ext self.source = source self.keyVersion = keyVersion + self.icon = icon } /// A push document, opened with `keys` when it is encrypted. Nil for a document that is @@ -117,10 +122,11 @@ struct PushItem: Identifiable, Equatable { } switch content { - case let .url(url, title, ext): + case let .url(url, title, ext, icon): self.init( id: id, url: url, userId: userId, createdAt: createdAt, title: title, type: .url, - ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion + ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion, + icon: icon ) case let .file(file): self.init( diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json new file mode 100644 index 0000000..b09a8c7 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "aac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png new file mode 100644 index 0000000..229acde Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json new file mode 100644 index 0000000..b365aae --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ai.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png new file mode 100644 index 0000000..bc85e0f Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json new file mode 100644 index 0000000..71e9399 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "apk.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png new file mode 100644 index 0000000..e8092db Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json new file mode 100644 index 0000000..c3cd644 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "avi.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png new file mode 100644 index 0000000..08b8557 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json new file mode 100644 index 0000000..d63495d --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "css.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png new file mode 100644 index 0000000..56bca64 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json new file mode 100644 index 0000000..df9c8ae --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "csv.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png new file mode 100644 index 0000000..551320d Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json new file mode 100644 index 0000000..0822a82 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "doc.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png new file mode 100644 index 0000000..ae4112e Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json new file mode 100644 index 0000000..407a489 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png new file mode 100644 index 0000000..1fa3e81 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json new file mode 100644 index 0000000..7710491 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gif.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png new file mode 100644 index 0000000..bdbe2cd Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json new file mode 100644 index 0000000..89e5132 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "html.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png new file mode 100644 index 0000000..aa4b93f Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json new file mode 100644 index 0000000..730ce27 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "indd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png new file mode 100644 index 0000000..1c1acd8 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json new file mode 100644 index 0000000..9dff12d --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpeg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png new file mode 100644 index 0000000..9345579 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json new file mode 100644 index 0000000..eee8df3 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png new file mode 100644 index 0000000..9345579 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json new file mode 100644 index 0000000..a1c5520 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "js.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png new file mode 100644 index 0000000..6fca286 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json new file mode 100644 index 0000000..acc0c7c --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mov.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png new file mode 100644 index 0000000..dd413f7 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json new file mode 100644 index 0000000..7897e4f --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png new file mode 100644 index 0000000..c1ce20e Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json new file mode 100644 index 0000000..157d36a --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png new file mode 100644 index 0000000..9d64a53 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json new file mode 100644 index 0000000..c9c4199 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pdf.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png new file mode 100644 index 0000000..95ab6c6 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json new file mode 100644 index 0000000..6508eb5 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "png.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png new file mode 100644 index 0000000..9e5fad2 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json new file mode 100644 index 0000000..a14a22b --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pproj.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png new file mode 100644 index 0000000..6308b56 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json new file mode 100644 index 0000000..ba5a913 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "psd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png new file mode 100644 index 0000000..d44694a Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json new file mode 100644 index 0000000..62e4cce --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tiff.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png new file mode 100644 index 0000000..16f4bb4 Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json new file mode 100644 index 0000000..7cc1eb6 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "txt.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png new file mode 100644 index 0000000..9215e1a Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json new file mode 100644 index 0000000..4464de7 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xls.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png new file mode 100644 index 0000000..a3a43ea Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json new file mode 100644 index 0000000..f6a92e3 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xml.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png new file mode 100644 index 0000000..777f56d Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png differ diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json new file mode 100644 index 0000000..eed17c7 --- /dev/null +++ b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "zip.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png new file mode 100644 index 0000000..587554a Binary files /dev/null and b/apps/ios/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png differ diff --git a/apps/ios/PushThat/Services/PushContent.swift b/apps/ios/PushThat/Services/PushContent.swift index c3f7ba1..52d32b0 100644 --- a/apps/ios/PushThat/Services/PushContent.swift +++ b/apps/ios/PushThat/Services/PushContent.swift @@ -1,13 +1,18 @@ import Foundation +import ImageIO +import UniformTypeIdentifiers /// What a push's envelope holds (docs/e2ee.md, "The push envelope"), and the keys this -/// device seals and opens it with. Foundation, CryptoKit and Security only: the app, the -/// share extension, the notification service extension and the tests all compile it, and -/// only the first two have Firestore. +/// device seals and opens it with. Foundation, CryptoKit, Security and ImageIO only: the +/// app, the share extension, the notification service extension and the tests all compile +/// it, and only the first two have Firestore. /// /// This file is the same in `apps/ios` and `apps/macos`. enum PushContent: Equatable { - case url(url: String, title: String, ext: String) + /// `icon` is the site's icon (`PushIcon`), nil when the push has none. It defaults to + /// nil so that a push made before a page is read, which is every push at creation, need + /// not say so. + case url(url: String, title: String, ext: String, icon: String? = nil) case file(PushFile) enum ContentError: LocalizedError { @@ -41,7 +46,7 @@ enum PushContent: Equatable { /// overlong title cannot crowd the URL's tokens out of the 3000. var searchText: String { switch self { - case let .url(url, title, _): return String(title.prefix(Self.maxTitleLength)) + " " + url + case let .url(url, title, _, _): return String(title.prefix(Self.maxTitleLength)) + " " + url case let .file(file): return file.name } } @@ -51,8 +56,12 @@ enum PushContent: Equatable { func json() throws -> Data { let object: [String: Any] switch self { - case let .url(url, title, ext): - object = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + case let .url(url, title, ext, icon): + var fields: [String: Any] = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + // Left out rather than written empty when there is none, which is how every + // push before icons reads and what the vectors' URL envelope holds. + if let icon, !icon.isEmpty { fields["icon"] = icon } + object = fields case let .file(file): if let fk = file.fk, let np = file.np { object = [ @@ -73,7 +82,10 @@ enum PushContent: Equatable { switch type { case "url": guard let url = object["url"] as? String else { throw ContentError.malformed } - self = .url(url: url, title: object["title"] as? String ?? "", ext: ext) + // Absent on a push from before icons, and "" means none as well (docs/e2ee.md, + // "Icons"), so both read as nil and nothing downstream tells them apart. + let icon = (object["icon"] as? String).flatMap { $0.isEmpty ? nil : $0 } + self = .url(url: url, title: object["title"] as? String ?? "", ext: ext, icon: icon) case "file": guard let name = object["name"] as? String else { throw ContentError.malformed } let mime = object["mime"] as? String ?? "" @@ -101,6 +113,35 @@ enum PushContent: Equatable { return (enc, tokens) } + /// The rewrite of a URL push after its page was scraped (docs/e2ee.md, "Titles" and + /// "Icons"), or nil when there is nothing to write: the rules Android's + /// `PushDocument.retitle` follows. It is rewritten when the title changed or an icon was + /// found for a push that had none, and a push keeps the icon it has. When the envelope + /// with an icon would be over the rules' bound the title is written alone, since the + /// title is what the row is read by. With no new title that leaves nothing to write: + /// sealing the same title again cost every listening device a write, and for a page with + /// no title, one on every launch. Still too large without the icon, it throws `tooLarge` + /// as before icons. + static func rescraped(_ current: PushContent, title: String?, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String])? { + guard case let .url(url, oldTitle, ext, oldIcon) = current else { return nil } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + let newTitle = title + .map { String($0.prefix(maxTitleLength)) } + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || $0 == oldTitle ? nil : $0 } + let hadIcon = !(oldIcon ?? "").isEmpty + let newIcon = icon.flatMap { $0.isEmpty || hadIcon ? nil : $0 } + guard newTitle != nil || newIcon != nil else { return nil } + let keptIcon = newIcon ?? oldIcon + do { + return try PushContent.url(url: url, title: newTitle ?? oldTitle, ext: ext, icon: keptIcon).seal(keys: keys, pushId: pushId) + } catch ContentError.tooLarge where !(keptIcon ?? "").isEmpty { + // The icon goes even when it was the push's own: the URL and new title did not + // fit beside it, and the title is worth more than the picture. + guard let newTitle else { return nil } + return try PushContent.url(url: url, title: newTitle, ext: ext).seal(keys: keys, pushId: pushId) + } + } + /// Opens a push's envelope, or nil when this device cannot: a push made under another /// account key (`keyVersion` differs), or one that does not open or parse. Such a push /// is shown locked. The AAD is made from the document's own `userId` and id. @@ -202,3 +243,252 @@ struct PreparedPush: Codable, Equatable { var enc: E2eeCrypto.Envelope { E2eeCrypto.Envelope(v: encVersion, n: encNonce, ct: encCiphertext) } } + +/// A URL push's icon (docs/e2ee.md, "Icons"): the standard base64 of a PNG no larger than +/// 64 by 64 pixels and no more than 4096 bytes, carried in the envelope with the title so +/// that no device asks anyone for it in order to draw the list. Google's favicon service +/// drew it before, and so learned the host of every pushed link each time a list was drawn. +/// +/// What needs no network is here rather than in `TitleScraperService`, which does the +/// fetching: this file is compiled by both apps and by the tests (which have no Firebase, +/// and a new file would mean a new entry in each project), so the candidate order, the +/// bounds and the decoding for display are written once per app and checked. ImageIO +/// rather than UIImage or NSImage for the same reason: it opens ICO and PNG on iOS and +/// macOS alike, so this is the same in both apps. +enum PushIcon { + /// The longest side, in pixels, an icon is stored at. + static let maxSide = 64 + /// The side tried once more when the PNG at `maxSide` is over `maxBytes`. + static let fallbackSide = 32 + /// The most bytes of PNG an icon may be, 5464 characters of base64: with a URL and a + /// title it still fits `enc.ct`'s 16384 for every ordinary link. + static let maxBytes = 4096 + /// How much of each candidate is read. A larger file is passed over rather than cut, + /// since a truncated image can still decode, with its lower part missing. + static let maxDownloadBytes = 256 * 1024 + /// Frames longer than this on a side are passed over: a frame is decoded whole to be + /// shrunk, and 256 KiB of PNG can hold a frame that would take hundreds of megabytes. + /// No icon worth drawing at 64 pixels is that large. + static let maxSourceSide = 2048 + /// The most `<link>` candidates tried before `/favicon.ico`. A page names a handful at + /// most; the cap keeps one that names hundreds from turning a scrape into hundreds of + /// requests. The same on every client (docs/e2ee.md, "Icons"). + static let maxLinkCandidates = 8 + + // MARK: Candidates + + /// The URLs to try, best first, for the page whose HTML is `html` and whose final URL + /// (after redirects) is `pageURL`: every `apple-touch-icon` link, then every `icon` + /// link (`shortcut icon` included) that is not SVG by its type or its path, then + /// `/favicon.ico` at the page's origin. Only http and https, each once, and at most + /// `maxLinkCandidates` links. A `<base href>` is not followed, as on every client: the + /// final URL is what all of them agree on. + /// + /// SVG is left out because ImageIO does not draw it: fetching one would be a request + /// the site sees for nothing. The spec leaves the `apple-touch-icon` links unfiltered, + /// and so does this; one that does not decode is simply passed over. + static func candidates(html: String, pageURL: URL) -> [URL] { + var touchIcons: [URL] = [] + var icons: [URL] = [] + for attributes in linkTags(in: html) { + guard let rel = attributes["rel"], let href = attributes["href"], + let url = resolve(href, against: pageURL) + else { continue } + let rels = Set(rel.lowercased().split(whereSeparator: \.isWhitespace).map(String.init)) + if rels.contains("apple-touch-icon") || rels.contains("apple-touch-icon-precomposed") { + touchIcons.append(url) + } else if rels.contains("icon") { + let type = attributes["type"]?.trimmingCharacters(in: .whitespaces).lowercased() + guard type != "image/svg+xml", url.pathExtension.lowercased() != "svg" else { continue } + icons.append(url) + } + } + var seen = Set<URL>() + var all = Array((touchIcons + icons).filter { seen.insert($0).inserted }.prefix(maxLinkCandidates)) + if let favicon = favicon(of: pageURL), !all.contains(favicon) { all.append(favicon) } + return all + } + + /// The attributes of every `<link>` tag, names lowercased, values unquoted and their + /// common entities decoded. A scanner rather than a parser, as the scraper finds the + /// `<title>`: a link's attributes are all an icon needs, and there is no HTML parser in + /// the project to lean on. It takes attributes in any order, quoted either way or not at + /// all, in any case, and a quoted value holding a `>`. + static func linkTags(in html: String) -> [[String: String]] { + linkPattern.matches(in: html, range: NSRange(html.startIndex..., in: html)).compactMap { match in + guard let range = Range(match.range(at: 1), in: html) else { return nil } + let tag = String(html[range]) + var attributes: [String: String] = [:] + for attribute in attributePattern.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)) { + guard let name = Range(attribute.range(at: 1), in: tag) else { continue } + let value = (2...4).lazy.compactMap { Range(attribute.range(at: $0), in: tag) }.first.map { String(tag[$0]) } ?? "" + let key = tag[name].lowercased() + // The first of a repeated attribute counts, as it does in a browser. + if attributes[key] == nil { attributes[key] = decodeEntities(value) } + } + return attributes + } + } + + private static let linkPattern = try! NSRegularExpression( + pattern: #"<link\b((?:[^>"']|"[^"]*"|'[^']*')*)>"#, options: .caseInsensitive + ) + private static let attributePattern = try! NSRegularExpression( + pattern: #"([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?"# + ) + + /// The entities an `href` holds in practice, `&` between query parameters above + /// all. `&` goes last so that `&lt;` reads as the text `<`. + private static func decodeEntities(_ value: String) -> String { + guard value.contains("&") else { return value } + var out = value + for (entity, text) in [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"), ("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] { + out = out.replacingOccurrences(of: entity, with: text, options: .caseInsensitive) + } + return out + } + + private static func resolve(_ href: String, against page: URL) -> URL? { + let href = href.trimmingCharacters(in: .whitespacesAndNewlines) + guard !href.isEmpty, let url = URL(string: href, relativeTo: page)?.absoluteURL, + isWeb(url.scheme), url.host != nil + else { return nil } + return url + } + + /// `/favicon.ico` at the page's origin: its scheme, host and port, nothing else. + private static func favicon(of page: URL) -> URL? { + guard isWeb(page.scheme), let host = page.host else { return nil } + var components = URLComponents() + components.scheme = page.scheme?.lowercased() + components.host = host + components.port = page.port + components.path = "/favicon.ico" + return components.url + } + + private static func isWeb(_ scheme: String?) -> Bool { + let scheme = scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + // MARK: Encoding + + /// What a candidate's bytes came to. + enum Encoded: Equatable { + /// The icon, within the bounds. + case icon(String) + /// ImageIO could not open them (or they are over `maxDownloadBytes`): the next + /// candidate is tried. + case undecodable + /// They opened, but the PNG is over `maxBytes` at 32 by 32 as well. The first + /// candidate that decodes decides, so there is no icon and no further candidate + /// is tried, as on every client (docs/e2ee.md, "Icons"). + case tooLarge + } + + static func encode(_ data: Data) -> Encoded { + guard data.count <= maxDownloadBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let index = bestFrame(of: source), + let image = CGImageSourceCreateImageAtIndex(source, index, nil) + else { return .undecodable } + for side in [maxSide, fallbackSide] { + guard let png = png(of: image, fitting: side) else { return .undecodable } + if png.count <= maxBytes { return .icon(png.base64EncodedString()) } + } + return .tooLarge + } + + /// The frame to shrink, by its longer side: the smallest of at least `maxSide`, so an + /// ICO's 64-pixel frame, drawn for that size, wins over its 256 one shrunk; else the + /// largest there is. The first wins a tie, which takes an animated GIF's first frame. + private static func bestFrame(of source: CGImageSource) -> Int? { + var best: (index: Int, side: Int)? + for index in 0..<CGImageSourceGetCount(source) { + guard let side = longerSide(source, index), side <= maxSourceSide else { continue } + guard let current = best else { + best = (index, side) + continue + } + let better = side >= maxSide + ? current.side < maxSide || side < current.side + : current.side < maxSide && side > current.side + if better { best = (index, side) } + } + return best?.index + } + + private static func pixelSize(_ source: CGImageSource, _ index: Int) -> (width: Int, height: Int)? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any], + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, + width > 0, height > 0 + else { return nil } + return (width, height) + } + + private static func longerSide(_ source: CGImageSource, _ index: Int) -> Int? { + pixelSize(source, index).map { max($0.width, $0.height) } + } + + /// `image` scaled down to fit `side` by `side` with its aspect kept, never up, as PNG. + /// Drawn into a context of exactly the target size rather than through ImageIO's + /// thumbnails, whose handling of an image already smaller than the limit is not + /// documented. Drawn in sRGB, so an icon in another color space (a CMYK JPEG, a P3 PNG) + /// comes out as every platform will show it; ImageIO marks that with a short `sRGB` + /// chunk, not a whole ICC profile, so it costs a few bytes of the 4096. + private static func png(of image: CGImage, fitting side: Int) -> Data? { + let scale = min(1, Double(side) / Double(max(image.width, image.height))) + let width = max(1, Int((Double(image.width) * scale).rounded())) + let height = max(1, Int((Double(image.height) * scale).rounded())) + guard let sRGB = CGColorSpace(name: CGColorSpace.sRGB), let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let scaled = context.makeImage() else { return nil } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, scaled, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return data as Data + } + + // MARK: Showing + + /// The image to draw for a push's `icon`, or nil when it is not a PNG within the + /// bounds, which draws the placeholder. Decoded once per icon and kept, failures too: a + /// row's body is evaluated far more often than its push changes. + static func image(_ icon: String) -> CGImage? { + let key = icon as NSString + if let decoded = cache.object(forKey: key) { return decoded.image } + let image = decode(icon) + cache.setObject(Decoded(image), forKey: key) + return image + } + + /// Opens an `icon` as the spec bounds it. Only PNG, and only up to 64 by 64, checked + /// before any pixel is decoded: whatever wrote the envelope, a small file must not + /// become a large image in a list of them. + static func decode(_ icon: String) -> CGImage? { + guard let data = Data(base64Encoded: icon), data.count <= maxBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetType(source) as String? == UTType.png.identifier, + let size = pixelSize(source, 0), size.width <= maxSide, size.height <= maxSide + else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + private final class Decoded { + let image: CGImage? + init(_ image: CGImage?) { self.image = image } + } + + private static let cache: NSCache<NSString, Decoded> = { + let cache = NSCache<NSString, Decoded>() + cache.countLimit = 500 + return cache + }() +} diff --git a/apps/ios/PushThat/Services/PushDocuments.swift b/apps/ios/PushThat/Services/PushDocuments.swift index b5f2508..722d2d1 100644 --- a/apps/ios/PushThat/Services/PushDocuments.swift +++ b/apps/ios/PushThat/Services/PushDocuments.swift @@ -137,27 +137,37 @@ enum PushDocuments { // MARK: - Titles - /// The scraped title written back to an encrypted URL push: exactly `enc` and + /// The scraped title and icon written back to an encrypted URL push: exactly `enc` and /// `searchTokens` (`isEncryptedRetitle` in the rules), the URL and ext as they were, the - /// new title under a fresh nonce. Only under the push's own key version, and only when - /// that is the one this device holds: a push made under another key is not ours to - /// rewrite. Last write wins. - static func retitle( - firestore: Firestore, - keys: PushKeys, - pushId: String, - userId: String, - keyVersion: Int?, - url: String, - ext: String, - title: String - ) async throws { - guard keyVersion == keys.keyVersion, userId == keys.uid else { return } - let sealed = try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) - try await firestore.collection("pushes").document(pushId).updateData([ - "enc": sealed.enc.firestoreData, - "searchTokens": sealed.searchTokens, - ]) + /// title and icon under a fresh nonce, by `PushContent.rescraped`'s rules. Only a push + /// this device can open: one under another key version is not ours to rewrite. + /// + /// Written over the push as it is at the write, in a transaction, not as the list read + /// it before the scrape. The scrape takes seconds, and another device scraping the same + /// push may write meanwhile. With titles alone that did no harm, since both wrote the + /// same title; with icons the writes differ (one device's decoder can open an icon + /// another's refuses), and a title-only write landing after a title-and-icon one dropped + /// the icon for good, since a titled push is not scraped again. + static func retitle(firestore: Firestore, keys: PushKeys, pushId: String, title: String?, icon: String?) async throws { + let ref = firestore.collection("pushes").document(pushId) + _ = try await firestore.runTransaction { transaction, errorPointer in + do { + let data = try transaction.getDocument(ref).data() ?? [:] + guard let userId = data["userId"] as? String, + let type = data["type"] as? String, + let enc = E2eeCrypto.Envelope(firestore: data["enc"]), + let current = PushContent.open( + keys: keys, userId: userId, pushId: pushId, type: type, + keyVersion: (data["keyVersion"] as? NSNumber)?.intValue, enc: enc + ), + let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: pushId) + else { return nil } + transaction.updateData(["enc": sealed.enc.firestoreData, "searchTokens": sealed.searchTokens], forDocument: ref) + } catch { + errorPointer?.pointee = error as NSError + } + return nil + } } // MARK: - Errors diff --git a/apps/ios/PushThat/Services/TitleScraperService.swift b/apps/ios/PushThat/Services/TitleScraperService.swift index 3d10e34..593e528 100644 --- a/apps/ios/PushThat/Services/TitleScraperService.swift +++ b/apps/ios/PushThat/Services/TitleScraperService.swift @@ -1,11 +1,12 @@ import Foundation import FirebaseFirestore -/// Fills in the title of an encrypted URL push from the page itself (docs/e2ee.md, -/// "Titles"). No server fetches it, since a server that fetched the URL would know it: a -/// push goes out with an empty title and a native device writes the title back, for its -/// own pushes and for one it received untitled (a web push, which has no way to read a -/// cross-origin page). +/// Fills in the title and the icon of an encrypted URL push from the page itself +/// (docs/e2ee.md, "Titles" and "Icons"). No server fetches them, since a server that +/// fetched the URL would know it: a push goes out with an empty title and a native device +/// writes the title back, for its own pushes and for one it received untitled (a web push, +/// which has no way to read a cross-origin page). The icon comes in the same pass, so that +/// no device has to ask anyone for it in order to draw the list. /// /// The app calls it for every untitled URL push the list shows, whoever made it, which /// covers the share extension's pushes (the extension is gone before a page could answer) @@ -20,40 +21,56 @@ actor TitleScraperService { private init() {} - /// Scrapes the page's title and rewrites the push's envelope with it: exactly `enc` and - /// `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). A - /// failure is logged; the push keeps showing its URL. - func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, ext: String, keys: PushKeys) async { + /// Scrapes the page and rewrites the push's envelope with what it found + /// (`PushDocuments.retitle`), only under the push's own key version. A push that already + /// has an icon does not fetch another, since it keeps the one it has either way. A + /// failure is logged; the push keeps showing what it showed. + func scrapeAndUpdate(pushId: String, keyVersion: Int?, url: String, hasIcon: Bool, keys: PushKeys) async { guard keyVersion == keys.keyVersion, attempted.insert(pushId).inserted else { return } - guard let title = await scrapeTitle(from: url), !title.isEmpty else { return } + guard let page = await scrape(from: url, withIcon: !hasIcon), page.title != nil || page.icon != nil else { return } do { - try await PushDocuments.retitle( - firestore: db, keys: keys, pushId: pushId, userId: userId, - keyVersion: keyVersion, url: url, ext: ext, title: title - ) + try await PushDocuments.retitle(firestore: db, keys: keys, pushId: pushId, title: page.title, icon: page.icon) } catch { print("Failed to update title: \(type(of: error))") } } - /// How much of a page is read for its title. + /// How much of a page is read for its title and its icon links. private static let maxPageBytes = 256 * 1024 - /// Scrape the title from a URL - func scrapeTitle(from urlString: String) async -> String? { + /// How long the icon candidates are tried for, after the page. + private static let iconBudget: TimeInterval = 30 + + /// The session every request of a scrape goes through. `timeoutInterval` bounds only a + /// gap between bytes, so a server sending one byte every few seconds held a request open + /// indefinitely, and the icon adds up to nine requests to the page's. The resource + /// timeout bounds each one start to finish, as `callTimeout` does on Android. + private static let session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForResource = 15 + return URLSession(configuration: configuration) + }() + + /// Sent with the page's request and its icons' alike, so the site sees one browser. + private static let userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + + /// The page's title and, when `withIcon`, its icon (`PushIcon`), or nil when the URL is + /// not an HTML page that answered. The title is nil when the page has none this reads. + private func scrape(from urlString: String, withIcon: Bool) async -> (title: String?, icon: String?)? { guard let url = URL(string: urlString) else { return nil } do { var request = URLRequest(url: url) - request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 // Streamed, not `data(for:)`: the pushes left untitled are mostly links to files // (PDFs, video, archives), which have no title to find, and the list asks for // every one it shows. Only an HTML page is read, and only its first 256 KiB, where // the <title> is; `timeoutInterval` bounds a stall, not a download, so without the - // cap a large file would be pulled into memory on every launch. - let (bytes, response) = try await URLSession.shared.bytes(for: request) + // cap a large file would be pulled into memory on every launch. Nor is an icon + // looked for on anything but a page: a link to a file shows its type's icon. + let (bytes, response) = try await Self.session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, @@ -69,16 +86,18 @@ actor TitleScraperService { // The header's charset, else UTF-8, decoded strictly: a page in an encoding this // cannot read gets no title, rather than a garbled one written into the push for // every device. Up to three bytes are dropped first, the most the cap can cut from - // a character, so a page cut mid-character still reads. + // a character, so a page cut mid-character still reads. Such a page has no icon + // links to read either, but its origin's /favicon.ico is still a candidate. let encoding = httpResponse.textEncodingName .map { CFStringConvertIANACharSetNameToEncoding($0 as CFString) } .flatMap { $0 == kCFStringEncodingInvalidId ? nil : String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding($0)) } ?? .utf8 - guard let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first else { - return nil - } + let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first ?? "" - return extractTitle(from: html) + // Icons resolve against the URL the page came from after redirects, as a + // browser resolves them, not against the one that was pushed. + let icon = withIcon ? await fetchIcon(from: PushIcon.candidates(html: html, pageURL: httpResponse.url ?? url)) : nil + return (extractTitle(from: html), icon) } catch { // The error's type only: a URLError carries the page's full URL. print("Failed to scrape title: \(type(of: error))") @@ -86,6 +105,51 @@ actor TitleScraperService { } } + /// The icon made from the first candidate that ImageIO opens (`PushIcon.encode`), or + /// nil when none opens or the one that does is too large even at 32 by 32. One at a + /// time, best first, so a page whose first candidate is good costs one request more + /// than its title. + private func fetchIcon(from candidates: [URL]) async -> String? { + // A budget for the lot, checked between candidates: each request is bounded by the + // session, and nine of them in a row still made a long scrape. + let deadline = Date().addingTimeInterval(Self.iconBudget) + for candidate in candidates { + guard Date() < deadline else { return nil } + guard let data = await download(candidate) else { continue } + switch PushIcon.encode(data) { + case let .icon(icon): return icon + case .tooLarge: return nil + case .undecodable: continue + } + } + return nil + } + + /// A candidate's bytes, or nil when it does not answer 200 or is over + /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at + /// the cap rather than pulled into memory first. + private func download(_ url: URL) async -> Data? { + var request = URLRequest(url: url) + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 10 + do { + let (bytes, response) = try await Self.session.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, + httpResponse.expectedContentLength <= Int64(PushIcon.maxDownloadBytes) + else { return nil } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count > PushIcon.maxDownloadBytes { return nil } + } + return data + } catch { + // The error's type only, as for the page: a URLError carries the full URL. + print("Failed to fetch icon: \(type(of: error))") + return nil + } + } + /// Extract title from HTML using regex /// Note: For production, consider using SwiftSoup for proper HTML parsing private func extractTitle(from html: String) -> String? { diff --git a/apps/ios/PushThat/Utilities/FileTypeIcons.swift b/apps/ios/PushThat/Utilities/FileTypeIcons.swift index 61cfb61..7146272 100644 --- a/apps/ios/PushThat/Utilities/FileTypeIcons.swift +++ b/apps/ios/PushThat/Utilities/FileTypeIcons.swift @@ -1,10 +1,15 @@ import Foundation -/// Maps file extensions to icons hosted on PushThat CDN -/// Matches the Android SUPPORTED_FILE_ICONS list exactly +/// The file types with an icon of their own, drawn from the app's asset catalog as +/// `file-<ext>` (copies of `apps/web/public/files/<ext>.png`). Matches the Android list. +/// +/// They were loaded from pushthat.link/files, which put the type of every encrypted file a +/// list showed in the hosting logs (docs/e2ee.md, "Icons"); shipped in the app, drawing +/// one asks nobody anything. struct FileTypeIcons { - /// Set of supported file extensions that have custom icons + /// The extensions that have an icon. The one list: an extension added here needs its + /// `file-<ext>` image set in Assets.xcassets, in both apps. static let supported: Set<String> = [ "pproj", "zip", "indd", "csv", "css", "pdf", "aac", "png", "xls", "avi", "txt", "mp3", "tiff", "psd", "jpg", "jpeg", @@ -12,11 +17,10 @@ struct FileTypeIcons { "xml", "apk" ] - /// Returns the URL for a file type icon, or nil if not supported - static func iconURL(for fileExtension: String) -> URL? { - let ext = fileExtension.lowercased() - guard supported.contains(ext) else { return nil } - return URL(string: "https://pushthat.link/files/\(ext).png") + /// The asset catalog name of the icon for `fileExtension`, or nil when it has none. + static func assetName(for fileExtension: String?) -> String? { + guard let ext = fileExtension?.lowercased(), supported.contains(ext) else { return nil } + return "file-\(ext)" } /// Returns a SF Symbol name appropriate for the file type diff --git a/apps/ios/PushThat/ViewModels/PushListViewModel.swift b/apps/ios/PushThat/ViewModels/PushListViewModel.swift index 3314b86..57df87e 100644 --- a/apps/ios/PushThat/ViewModels/PushListViewModel.swift +++ b/apps/ios/PushThat/ViewModels/PushListViewModel.swift @@ -266,18 +266,19 @@ class PushListViewModel: ObservableObject { showError = true } - /// Untitled URL pushes get their page's title, whichever device made them + /// Untitled URL pushes get their page's title and icon, whichever device made them /// (`TitleScraperService`). In the background: the list does not wait for any page. + /// A titled push is not read again for an icon (docs/e2ee.md, "Icons"), so a push + /// from before icons keeps the placeholder rather than every list asking every site. private func scrapeUntitled(_ items: [PushItem], keys: PushKeys) { for item in items where item.type == .url && item.title.isEmpty { guard case .encrypted = item.source, let id = item.id else { continue } let url = item.url - let ext = item.ext ?? "" - let userId = item.userId + let hasIcon = !(item.icon ?? "").isEmpty let keyVersion = item.keyVersion Task.detached(priority: .utility) { await TitleScraperService.shared.scrapeAndUpdate( - pushId: id, userId: userId, keyVersion: keyVersion, url: url, ext: ext, keys: keys + pushId: id, keyVersion: keyVersion, url: url, hasIcon: hasIcon, keys: keys ) } } diff --git a/apps/ios/PushThat/Views/PushRowView.swift b/apps/ios/PushThat/Views/PushRowView.swift index 52e4872..f7fd64a 100644 --- a/apps/ios/PushThat/Views/PushRowView.swift +++ b/apps/ios/PushThat/Views/PushRowView.swift @@ -16,7 +16,7 @@ struct PushRowView: View { // Swivel checkbox / favicon SwivelCheckView( isChecked: isSelected, - imageURL: faviconURL, + image: icon, fallbackIcon: item.type == .file ? "doc.fill" : "link.circle.fill" ) .frame(width: 40, height: 40) @@ -126,18 +126,23 @@ struct PushRowView: View { return date.relativeFormatted() } - private var faviconURL: URL? { - // A locked push shows the placeholder: there is no URL or name to show one for. + /// What the row draws, all of it already on the device: nothing is fetched to draw a + /// row (docs/e2ee.md, "Icons"). A URL push draws the icon that came in it, else the + /// icon for its `ext` from the asset catalog (a link to a file has no page to take an + /// icon from); a file push draws its type's icon. Nil draws the placeholder a locked + /// push draws, also for an icon that does not decode. The order is the same on every + /// client. + /// + /// This was Google's favicon service for every link, which learned the host of each + /// pushed link whenever the list was drawn, and pushthat.link for file types, whose logs + /// learned the type of each encrypted file. + private var icon: Image? { + // A locked push shows the placeholder: there is nothing it can be known by. if item.isLocked { return nil } - - // Check for file extension icon first - if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()) { - return URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") + if item.type == .url, let icon = item.icon, let image = PushIcon.image(icon) { + return Image(decorative: image, scale: 1) } - - // Fall back to Google favicon service - guard let urlHost = URL(string: item.url)?.host else { return nil } - return URL(string: "https://www.google.com/s2/favicons?domain=\(urlHost)&sz=256") + return FileTypeIcons.assetName(for: item.ext).map { Image($0) } } } diff --git a/apps/ios/PushThat/Views/SwivelCheckView.swift b/apps/ios/PushThat/Views/SwivelCheckView.swift index 18740d8..12fcaf1 100644 --- a/apps/ios/PushThat/Views/SwivelCheckView.swift +++ b/apps/ios/PushThat/Views/SwivelCheckView.swift @@ -2,9 +2,12 @@ import SwiftUI /// Custom 3D flip checkbox that shows a favicon on the front and a checkmark on the back /// Matches the Android SwivelCheckView behavior +/// +/// It takes an image the caller already holds, not a URL: drawing a row fetches nothing +/// (docs/e2ee.md, "Icons"). `fallbackIcon` is drawn when there is none. struct SwivelCheckView: View { let isChecked: Bool - let imageURL: URL? + let image: Image? var fallbackIcon: String = "link.circle.fill" @State private var rotation: Double = 0 @@ -40,23 +43,12 @@ struct SwivelCheckView: View { private var frontSide: some View { Group { - if let url = imageURL { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 40, height: 40) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 8)) - case .failure: - fallbackIconView - @unknown default: - fallbackIconView - } - } + if let image { + image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) } else { fallbackIconView } @@ -83,27 +75,17 @@ struct SwivelCheckView: View { #Preview { VStack(spacing: 20) { - // Unchecked state - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + // Unchecked state, with no icon: the placeholder + SwivelCheckView(isChecked: false, image: nil) + .frame(width: 40, height: 40) // Checked state - SwivelCheckView( - isChecked: true, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: true, image: nil) + .frame(width: 40, height: 40) // With file icon - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://pushthat.link/files/pdf.png"), - fallbackIcon: "doc.fill" - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: false, image: Image("file-pdf"), fallbackIcon: "doc.fill") + .frame(width: 40, height: 40) // Interactive demo SwivelCheckViewDemo() @@ -116,11 +98,8 @@ struct SwivelCheckViewDemo: View { var body: some View { VStack { - SwivelCheckView( - isChecked: isChecked, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=apple.com&sz=256") - ) - .frame(width: 64, height: 64) + SwivelCheckView(isChecked: isChecked, image: Image("file-zip")) + .frame(width: 64, height: 64) .onTapGesture { isChecked.toggle() } diff --git a/apps/ios/PushThatNotificationService/NotificationService.swift b/apps/ios/PushThatNotificationService/NotificationService.swift index 74fa510..09e4834 100644 --- a/apps/ios/PushThatNotificationService/NotificationService.swift +++ b/apps/ios/PushThatNotificationService/NotificationService.swift @@ -109,7 +109,7 @@ final class NotificationService: UNNotificationServiceExtension { let result = base.mutableCopy() as! UNMutableNotificationContent switch content { - case let .url(url, title, _): + case let .url(url, title, _, _): result.title = title.isEmpty ? (URL(string: url)?.host ?? url) : title result.body = url var userInfo = result.userInfo diff --git a/apps/ios/PushThatTests/E2eeVectorTests.swift b/apps/ios/PushThatTests/E2eeVectorTests.swift index 0ae6619..2792582 100644 --- a/apps/ios/PushThatTests/E2eeVectorTests.swift +++ b/apps/ios/PushThatTests/E2eeVectorTests.swift @@ -1,4 +1,6 @@ import XCTest +import ImageIO +import UniformTypeIdentifiers /// Checks `E2eeCrypto` and `SearchTokenGenerator` against vectors 1 to 6 of /// `packages/push-test/vectors.json` (docs/e2ee.md, "Test vectors"), byte for byte. The file is copied into the test bundle @@ -218,6 +220,7 @@ final class E2eeVectorTests: XCTestCase { let contents: [PushContent] = [ .url(url: "https://example.com/a", title: "", ext: ""), .url(url: "https://example.com/b.pdf", title: "Example \u{e9}", ext: "pdf"), + .url(url: "https://example.com/c", title: "Example", ext: "", icon: "iVBORw0KGgo="), .file(PushFile(name: "Report (final) v2.pdf", mime: "application/pdf", size: 3_145_729, ext: "pdf", fk: E2eeCrypto.newFileKey(), np: E2eeCrypto.newFilePrefix())), .file(PushFile(name: "old.txt", mime: "", size: 0, ext: "txt", fk: nil, np: nil)), @@ -245,7 +248,7 @@ final class E2eeVectorTests: XCTestCase { let long = PushContent.url(url: "https://example.com", title: String(repeating: "t", count: 5000), ext: "") let opened = try XCTUnwrap(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: PreparedPush(content: long, keys: keys, pushId: "p").enc)) - guard case let .url(_, title, _) = opened else { return XCTFail("not a URL push") } + guard case let .url(_, title, _, _) = opened else { return XCTFail("not a URL push") } XCTAssertEqual(title.count, PushContent.maxTitleLength) let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "") @@ -254,6 +257,69 @@ final class E2eeVectorTests: XCTestCase { } } + /// `icon` is optional: absent and "" both read as none, and none is not written, so a + /// push without one is the same plaintext as before icons. + func testURLIconIsOptional() throws { + let without = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "").json()) as? [String: Any]) + XCTAssertNil(without["icon"]) + let empty = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "").json()) as? [String: Any]) + XCTAssertNil(empty["icon"]) + let with = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").json()) as? [String: Any]) + XCTAssertEqual(with["icon"] as? String, "AAAA") + + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":"AAAA"}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: "AAAA")) + // The icon is not searched. + XCTAssertEqual(PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").searchText, "T https://example.com") + } + + /// What a scrape writes back (`PushContent.rescraped`), opened again to compare. + private func rescrape(_ current: PushContent, title: String?, icon: String?) throws -> PushContent?? { + let keys = try vectorKeys() + guard let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: "p") else { return .some(nil) } + return PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc) + } + + /// A rewrite happens for a new title or a first icon, keeps the icon a push has, and + /// does not happen when the scrape found nothing new. + func testARescrapeWritesOnlyANewTitleOrAFirstIcon() throws { + let url = "https://example.com" + let untitled = PushContent.url(url: url, title: "", ext: "", icon: nil) + let titled = PushContent.url(url: url, title: "T", ext: "", icon: "AAAA") + + XCTAssertEqual(try rescrape(untitled, title: "T", icon: "AAAA"), .url(url: url, title: "T", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: "AAAA"), .url(url: url, title: "", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: "T", icon: nil), .url(url: url, title: "T", ext: "", icon: nil)) + // An icon the push has is kept, and not replaced by another. + XCTAssertEqual(try rescrape(titled, title: "U", icon: "BBBB"), .url(url: url, title: "U", ext: "", icon: "AAAA")) + // Nothing new: the same title, a blank one, or an icon where there is one already. + XCTAssertEqual(try rescrape(titled, title: "T", icon: "BBBB"), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: " ", icon: nil), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: ""), .some(nil)) + } + + /// When the envelope with the icon would be over the bound, a new title is written alone, + /// and an icon that was the only new thing is no reason to write. + func testARescrapeDropsAnIconThatDoesNotFit() throws { + let icon = String(repeating: "A", count: 5464) + let long = "https://example.com/" + String(repeating: "a", count: 9000) + let untitled = PushContent.url(url: long, title: "", ext: "", icon: nil) + + XCTAssertEqual(try rescrape(untitled, title: "T", icon: icon), .url(url: long, title: "T", ext: "", icon: nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: icon), .some(nil)) + XCTAssertEqual(try rescrape(.url(url: "https://example.com", title: "", ext: "", icon: nil), title: "T", icon: icon), + .url(url: "https://example.com", title: "T", ext: "", icon: icon)) + + let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "", icon: nil) + XCTAssertThrowsError(try rescrape(absurd, title: "T", icon: icon)) { error in + XCTAssertEqual(error as? PushContent.ContentError, .tooLarge) + } + } + // MARK: - Vector 4: the blind index func testSearchVector() throws { @@ -479,3 +545,135 @@ final class E2eeVectorTests: XCTestCase { XCTAssertEqual(PushFile.ext(of: "README"), "") } } + +/// The icon's capture and display (docs/e2ee.md, "Icons"). No network: the candidates are +/// read from a string and the images are made here. +final class PushIconTests: XCTestCase { + private let page = URL(string: "https://www.example.com:8443/dir/page?q=1#top")! + + func testCandidateOrder() { + let html = """ + <html><head> + <LINK href='/a.png' REL="Shortcut Icon"> + <link rel=icon type="image/svg+xml" href="/vector"> + <link rel="icon" href="logo.SVG?v=1"> + <link data-x="a>b" rel="apple-touch-icon" href="https://cdn.example.com/t.png?a=1&b=2"> + <link rel="apple-touch-icon-precomposed" href="javascript:void(0)"> + <link rel="stylesheet" href="s.css"> + <link rel="icon" href="//other.example.com/i.ico" /> + <linkx rel="icon" href="/not-a-link.png"> + <link rel="icon" href="/a.png"> + <link rel="icon" href="data:image/png;base64,AAAA"> + """ + XCTAssertEqual(PushIcon.candidates(html: html, pageURL: page).map(\.absoluteString), [ + "https://cdn.example.com/t.png?a=1&b=2", + "https://www.example.com:8443/a.png", + "https://other.example.com/i.ico", + "https://www.example.com:8443/favicon.ico", + ]) + } + + func testCandidatesResolveRelativeToThePage() { + XCTAssertEqual(PushIcon.candidates(html: #"<link href="icons/x.ico" rel="icon">"#, pageURL: page).map(\.absoluteString), [ + "https://www.example.com:8443/dir/icons/x.ico", + "https://www.example.com:8443/favicon.ico", + ]) + // A <base href> is not followed. + XCTAssertEqual(PushIcon.candidates(html: #"<base href="https://elsewhere.example.com/"><link rel="icon" href="x.ico">"#, pageURL: page).first?.absoluteString, + "https://www.example.com:8443/dir/x.ico") + // No links: only the origin's favicon. Not http: nothing. + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "http://example.com/a/b")!).map(\.absoluteString), ["http://example.com/favicon.ico"]) + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "ftp://example.com/a")!), []) + } + + func testAtMostEightLinksThenTheFavicon() { + let html = (1...20).map { #"<link rel="icon" href="/\#($0).png">"# }.joined() + let candidates = PushIcon.candidates(html: html, pageURL: page).map(\.path) + XCTAssertEqual(candidates, (1...8).map { "/\($0).png" } + ["/favicon.ico"]) + } + + func testEncodeScalesDownKeepingAspect() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 200, height: 100)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [64, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + func testEncodeNeverScalesUp() throws { + let image = try XCTUnwrap(PushIcon.decode(try XCTUnwrap(icon(PushIcon.encode(png(width: 16, height: 16)))))) + XCTAssertEqual([image.width, image.height], [16, 16]) + } + + /// Gray noise is over 4096 bytes at 64 pixels and under it at 32. + func testEncodeFallsBackTo32() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 64, height: 64, noise: .gray)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [32, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + /// Noise in every channel, alpha too, is over 4096 bytes even at 32 pixels. + func testEncodeGivesUpOverTheBound() { + XCTAssertEqual(PushIcon.encode(png(width: 48, height: 48, noise: .everyChannel)), .tooLarge) + } + + func testEncodeRefusesWhatDoesNotDecode() { + XCTAssertEqual(PushIcon.encode(Data("<html>not an image</html>".utf8)), .undecodable) + XCTAssertEqual(PushIcon.encode(Data(count: PushIcon.maxDownloadBytes + 1)), .undecodable) + } + + /// Display opens only a PNG within 64 by 64. + func testDecodeChecksTheBounds() throws { + XCTAssertNotNil(PushIcon.decode(png(width: 64, height: 64).base64EncodedString())) + XCTAssertNil(PushIcon.decode(png(width: 65, height: 10).base64EncodedString())) + XCTAssertNil(PushIcon.decode(jpeg(width: 16, height: 16).base64EncodedString())) + XCTAssertNil(PushIcon.decode("not base64")) + XCTAssertNil(PushIcon.decode("")) + } + + // MARK: - Images + + private func icon(_ encoded: PushIcon.Encoded) -> String? { + if case let .icon(icon) = encoded { return icon } + return nil + } + + private enum Noise { case none, gray, everyChannel } + + /// A test image: flat gray, or noise from a fixed seed, so every run makes the same one. + private func image(width: Int, height: Int, noise: Noise) -> CGImage { + let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let pixels = context.data!.assumingMemoryBound(to: UInt8.self) + var seed: UInt32 = 1 + for i in 0..<(width * height) { + seed = seed &* 1_664_525 &+ 1_013_904_223 + let byte = { (shift: UInt32) in UInt8(truncatingIfNeeded: seed >> shift) } + switch noise { + case .none: + pixels[i * 4] = 128; pixels[i * 4 + 1] = 128; pixels[i * 4 + 2] = 128; pixels[i * 4 + 3] = 255 + case .gray: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(24); pixels[i * 4 + 2] = byte(24); pixels[i * 4 + 3] = 255 + case .everyChannel: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(16); pixels[i * 4 + 2] = byte(8); pixels[i * 4 + 3] = max(byte(4), 1) + } + } + return context.makeImage()! + } + + private func png(width: Int, height: Int, noise: Noise = .none) -> Data { + encoded(image(width: width, height: height, noise: noise), as: .png) + } + + private func jpeg(width: Int, height: Int) -> Data { + encoded(image(width: width, height: height, noise: .none), as: .jpeg) + } + + private func encoded(_ image: CGImage, as type: UTType) -> Data { + let data = NSMutableData() + let destination = CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(destination, image, nil) + CGImageDestinationFinalize(destination) + return data as Data + } +} diff --git a/apps/macos/PushThat/Models/PushItem.swift b/apps/macos/PushThat/Models/PushItem.swift index eb11503..4345991 100644 --- a/apps/macos/PushThat/Models/PushItem.swift +++ b/apps/macos/PushThat/Models/PushItem.swift @@ -20,6 +20,9 @@ struct PushItem: Identifiable, Equatable { let source: Source /// The push's `keyVersion`, which a retitle has to match. let keyVersion: Int? + /// An encrypted URL push's icon (`PushIcon`), nil when it has none. Kept as the + /// envelope holds it, so that a retitle writes it back unchanged. + let icon: String? enum PushType: String { case url @@ -77,7 +80,8 @@ struct PushItem: Identifiable, Equatable { type: PushType = .url, ext: String? = nil, source: Source = .plaintext, - keyVersion: Int? = nil + keyVersion: Int? = nil, + icon: String? = nil ) { self.id = id self.url = url @@ -88,6 +92,7 @@ struct PushItem: Identifiable, Equatable { self.ext = ext self.source = source self.keyVersion = keyVersion + self.icon = icon } /// A push document, opened with `keys` when it is encrypted. Nil for a document that is @@ -117,10 +122,11 @@ struct PushItem: Identifiable, Equatable { } switch content { - case let .url(url, title, ext): + case let .url(url, title, ext, icon): self.init( id: id, url: url, userId: userId, createdAt: createdAt, title: title, type: .url, - ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion + ext: ext.isEmpty ? nil : ext, source: .encrypted(file: nil, objectPath: nil), keyVersion: keyVersion, + icon: icon ) case let .file(file): self.init( diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json new file mode 100644 index 0000000..b09a8c7 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "aac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png new file mode 100644 index 0000000..229acde Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-aac.imageset/aac.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json new file mode 100644 index 0000000..b365aae --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ai.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png new file mode 100644 index 0000000..bc85e0f Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-ai.imageset/ai.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json new file mode 100644 index 0000000..71e9399 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "apk.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png new file mode 100644 index 0000000..e8092db Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-apk.imageset/apk.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json new file mode 100644 index 0000000..c3cd644 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "avi.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png new file mode 100644 index 0000000..08b8557 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-avi.imageset/avi.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json new file mode 100644 index 0000000..d63495d --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "css.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png new file mode 100644 index 0000000..56bca64 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-css.imageset/css.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json new file mode 100644 index 0000000..df9c8ae --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "csv.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png new file mode 100644 index 0000000..551320d Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-csv.imageset/csv.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json new file mode 100644 index 0000000..0822a82 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "doc.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png new file mode 100644 index 0000000..ae4112e Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-doc.imageset/doc.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json new file mode 100644 index 0000000..407a489 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flac.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png new file mode 100644 index 0000000..1fa3e81 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-flac.imageset/flac.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json new file mode 100644 index 0000000..7710491 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gif.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png new file mode 100644 index 0000000..bdbe2cd Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-gif.imageset/gif.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json new file mode 100644 index 0000000..89e5132 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "html.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png new file mode 100644 index 0000000..aa4b93f Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-html.imageset/html.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json new file mode 100644 index 0000000..730ce27 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "indd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png new file mode 100644 index 0000000..1c1acd8 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-indd.imageset/indd.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json new file mode 100644 index 0000000..9dff12d --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpeg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png new file mode 100644 index 0000000..9345579 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpeg.imageset/jpeg.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json new file mode 100644 index 0000000..eee8df3 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "jpg.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png new file mode 100644 index 0000000..9345579 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-jpg.imageset/jpg.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json new file mode 100644 index 0000000..a1c5520 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "js.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png new file mode 100644 index 0000000..6fca286 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-js.imageset/js.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json new file mode 100644 index 0000000..acc0c7c --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mov.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png new file mode 100644 index 0000000..dd413f7 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-mov.imageset/mov.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json new file mode 100644 index 0000000..7897e4f --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png new file mode 100644 index 0000000..c1ce20e Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp3.imageset/mp3.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json new file mode 100644 index 0000000..157d36a --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mp4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png new file mode 100644 index 0000000..9d64a53 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-mp4.imageset/mp4.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json new file mode 100644 index 0000000..c9c4199 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pdf.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png new file mode 100644 index 0000000..95ab6c6 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-pdf.imageset/pdf.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json new file mode 100644 index 0000000..6508eb5 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "png.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png new file mode 100644 index 0000000..9e5fad2 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-png.imageset/png.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json new file mode 100644 index 0000000..a14a22b --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pproj.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png new file mode 100644 index 0000000..6308b56 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-pproj.imageset/pproj.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json new file mode 100644 index 0000000..ba5a913 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "psd.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png new file mode 100644 index 0000000..d44694a Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-psd.imageset/psd.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json new file mode 100644 index 0000000..62e4cce --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tiff.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png new file mode 100644 index 0000000..16f4bb4 Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-tiff.imageset/tiff.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json new file mode 100644 index 0000000..7cc1eb6 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "txt.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png new file mode 100644 index 0000000..9215e1a Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-txt.imageset/txt.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json new file mode 100644 index 0000000..4464de7 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xls.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png new file mode 100644 index 0000000..a3a43ea Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-xls.imageset/xls.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json new file mode 100644 index 0000000..f6a92e3 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xml.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png new file mode 100644 index 0000000..777f56d Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-xml.imageset/xml.png differ diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json new file mode 100644 index 0000000..eed17c7 --- /dev/null +++ b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "zip.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png new file mode 100644 index 0000000..587554a Binary files /dev/null and b/apps/macos/PushThat/Resources/Assets.xcassets/file-zip.imageset/zip.png differ diff --git a/apps/macos/PushThat/Services/PushContent.swift b/apps/macos/PushThat/Services/PushContent.swift index c3f7ba1..52d32b0 100644 --- a/apps/macos/PushThat/Services/PushContent.swift +++ b/apps/macos/PushThat/Services/PushContent.swift @@ -1,13 +1,18 @@ import Foundation +import ImageIO +import UniformTypeIdentifiers /// What a push's envelope holds (docs/e2ee.md, "The push envelope"), and the keys this -/// device seals and opens it with. Foundation, CryptoKit and Security only: the app, the -/// share extension, the notification service extension and the tests all compile it, and -/// only the first two have Firestore. +/// device seals and opens it with. Foundation, CryptoKit, Security and ImageIO only: the +/// app, the share extension, the notification service extension and the tests all compile +/// it, and only the first two have Firestore. /// /// This file is the same in `apps/ios` and `apps/macos`. enum PushContent: Equatable { - case url(url: String, title: String, ext: String) + /// `icon` is the site's icon (`PushIcon`), nil when the push has none. It defaults to + /// nil so that a push made before a page is read, which is every push at creation, need + /// not say so. + case url(url: String, title: String, ext: String, icon: String? = nil) case file(PushFile) enum ContentError: LocalizedError { @@ -41,7 +46,7 @@ enum PushContent: Equatable { /// overlong title cannot crowd the URL's tokens out of the 3000. var searchText: String { switch self { - case let .url(url, title, _): return String(title.prefix(Self.maxTitleLength)) + " " + url + case let .url(url, title, _, _): return String(title.prefix(Self.maxTitleLength)) + " " + url case let .file(file): return file.name } } @@ -51,8 +56,12 @@ enum PushContent: Equatable { func json() throws -> Data { let object: [String: Any] switch self { - case let .url(url, title, ext): - object = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + case let .url(url, title, ext, icon): + var fields: [String: Any] = ["url": url, "title": String(title.prefix(Self.maxTitleLength)), "ext": ext] + // Left out rather than written empty when there is none, which is how every + // push before icons reads and what the vectors' URL envelope holds. + if let icon, !icon.isEmpty { fields["icon"] = icon } + object = fields case let .file(file): if let fk = file.fk, let np = file.np { object = [ @@ -73,7 +82,10 @@ enum PushContent: Equatable { switch type { case "url": guard let url = object["url"] as? String else { throw ContentError.malformed } - self = .url(url: url, title: object["title"] as? String ?? "", ext: ext) + // Absent on a push from before icons, and "" means none as well (docs/e2ee.md, + // "Icons"), so both read as nil and nothing downstream tells them apart. + let icon = (object["icon"] as? String).flatMap { $0.isEmpty ? nil : $0 } + self = .url(url: url, title: object["title"] as? String ?? "", ext: ext, icon: icon) case "file": guard let name = object["name"] as? String else { throw ContentError.malformed } let mime = object["mime"] as? String ?? "" @@ -101,6 +113,35 @@ enum PushContent: Equatable { return (enc, tokens) } + /// The rewrite of a URL push after its page was scraped (docs/e2ee.md, "Titles" and + /// "Icons"), or nil when there is nothing to write: the rules Android's + /// `PushDocument.retitle` follows. It is rewritten when the title changed or an icon was + /// found for a push that had none, and a push keeps the icon it has. When the envelope + /// with an icon would be over the rules' bound the title is written alone, since the + /// title is what the row is read by. With no new title that leaves nothing to write: + /// sealing the same title again cost every listening device a write, and for a page with + /// no title, one on every launch. Still too large without the icon, it throws `tooLarge` + /// as before icons. + static func rescraped(_ current: PushContent, title: String?, icon: String?, keys: PushKeys, pushId: String) throws -> (enc: E2eeCrypto.Envelope, searchTokens: [String])? { + guard case let .url(url, oldTitle, ext, oldIcon) = current else { return nil } + // Cut as the envelope cuts it, so a long title read again is not taken for a change. + let newTitle = title + .map { String($0.prefix(maxTitleLength)) } + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || $0 == oldTitle ? nil : $0 } + let hadIcon = !(oldIcon ?? "").isEmpty + let newIcon = icon.flatMap { $0.isEmpty || hadIcon ? nil : $0 } + guard newTitle != nil || newIcon != nil else { return nil } + let keptIcon = newIcon ?? oldIcon + do { + return try PushContent.url(url: url, title: newTitle ?? oldTitle, ext: ext, icon: keptIcon).seal(keys: keys, pushId: pushId) + } catch ContentError.tooLarge where !(keptIcon ?? "").isEmpty { + // The icon goes even when it was the push's own: the URL and new title did not + // fit beside it, and the title is worth more than the picture. + guard let newTitle else { return nil } + return try PushContent.url(url: url, title: newTitle, ext: ext).seal(keys: keys, pushId: pushId) + } + } + /// Opens a push's envelope, or nil when this device cannot: a push made under another /// account key (`keyVersion` differs), or one that does not open or parse. Such a push /// is shown locked. The AAD is made from the document's own `userId` and id. @@ -202,3 +243,252 @@ struct PreparedPush: Codable, Equatable { var enc: E2eeCrypto.Envelope { E2eeCrypto.Envelope(v: encVersion, n: encNonce, ct: encCiphertext) } } + +/// A URL push's icon (docs/e2ee.md, "Icons"): the standard base64 of a PNG no larger than +/// 64 by 64 pixels and no more than 4096 bytes, carried in the envelope with the title so +/// that no device asks anyone for it in order to draw the list. Google's favicon service +/// drew it before, and so learned the host of every pushed link each time a list was drawn. +/// +/// What needs no network is here rather than in `TitleScraperService`, which does the +/// fetching: this file is compiled by both apps and by the tests (which have no Firebase, +/// and a new file would mean a new entry in each project), so the candidate order, the +/// bounds and the decoding for display are written once per app and checked. ImageIO +/// rather than UIImage or NSImage for the same reason: it opens ICO and PNG on iOS and +/// macOS alike, so this is the same in both apps. +enum PushIcon { + /// The longest side, in pixels, an icon is stored at. + static let maxSide = 64 + /// The side tried once more when the PNG at `maxSide` is over `maxBytes`. + static let fallbackSide = 32 + /// The most bytes of PNG an icon may be, 5464 characters of base64: with a URL and a + /// title it still fits `enc.ct`'s 16384 for every ordinary link. + static let maxBytes = 4096 + /// How much of each candidate is read. A larger file is passed over rather than cut, + /// since a truncated image can still decode, with its lower part missing. + static let maxDownloadBytes = 256 * 1024 + /// Frames longer than this on a side are passed over: a frame is decoded whole to be + /// shrunk, and 256 KiB of PNG can hold a frame that would take hundreds of megabytes. + /// No icon worth drawing at 64 pixels is that large. + static let maxSourceSide = 2048 + /// The most `<link>` candidates tried before `/favicon.ico`. A page names a handful at + /// most; the cap keeps one that names hundreds from turning a scrape into hundreds of + /// requests. The same on every client (docs/e2ee.md, "Icons"). + static let maxLinkCandidates = 8 + + // MARK: Candidates + + /// The URLs to try, best first, for the page whose HTML is `html` and whose final URL + /// (after redirects) is `pageURL`: every `apple-touch-icon` link, then every `icon` + /// link (`shortcut icon` included) that is not SVG by its type or its path, then + /// `/favicon.ico` at the page's origin. Only http and https, each once, and at most + /// `maxLinkCandidates` links. A `<base href>` is not followed, as on every client: the + /// final URL is what all of them agree on. + /// + /// SVG is left out because ImageIO does not draw it: fetching one would be a request + /// the site sees for nothing. The spec leaves the `apple-touch-icon` links unfiltered, + /// and so does this; one that does not decode is simply passed over. + static func candidates(html: String, pageURL: URL) -> [URL] { + var touchIcons: [URL] = [] + var icons: [URL] = [] + for attributes in linkTags(in: html) { + guard let rel = attributes["rel"], let href = attributes["href"], + let url = resolve(href, against: pageURL) + else { continue } + let rels = Set(rel.lowercased().split(whereSeparator: \.isWhitespace).map(String.init)) + if rels.contains("apple-touch-icon") || rels.contains("apple-touch-icon-precomposed") { + touchIcons.append(url) + } else if rels.contains("icon") { + let type = attributes["type"]?.trimmingCharacters(in: .whitespaces).lowercased() + guard type != "image/svg+xml", url.pathExtension.lowercased() != "svg" else { continue } + icons.append(url) + } + } + var seen = Set<URL>() + var all = Array((touchIcons + icons).filter { seen.insert($0).inserted }.prefix(maxLinkCandidates)) + if let favicon = favicon(of: pageURL), !all.contains(favicon) { all.append(favicon) } + return all + } + + /// The attributes of every `<link>` tag, names lowercased, values unquoted and their + /// common entities decoded. A scanner rather than a parser, as the scraper finds the + /// `<title>`: a link's attributes are all an icon needs, and there is no HTML parser in + /// the project to lean on. It takes attributes in any order, quoted either way or not at + /// all, in any case, and a quoted value holding a `>`. + static func linkTags(in html: String) -> [[String: String]] { + linkPattern.matches(in: html, range: NSRange(html.startIndex..., in: html)).compactMap { match in + guard let range = Range(match.range(at: 1), in: html) else { return nil } + let tag = String(html[range]) + var attributes: [String: String] = [:] + for attribute in attributePattern.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)) { + guard let name = Range(attribute.range(at: 1), in: tag) else { continue } + let value = (2...4).lazy.compactMap { Range(attribute.range(at: $0), in: tag) }.first.map { String(tag[$0]) } ?? "" + let key = tag[name].lowercased() + // The first of a repeated attribute counts, as it does in a browser. + if attributes[key] == nil { attributes[key] = decodeEntities(value) } + } + return attributes + } + } + + private static let linkPattern = try! NSRegularExpression( + pattern: #"<link\b((?:[^>"']|"[^"]*"|'[^']*')*)>"#, options: .caseInsensitive + ) + private static let attributePattern = try! NSRegularExpression( + pattern: #"([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?"# + ) + + /// The entities an `href` holds in practice, `&` between query parameters above + /// all. `&` goes last so that `&lt;` reads as the text `<`. + private static func decodeEntities(_ value: String) -> String { + guard value.contains("&") else { return value } + var out = value + for (entity, text) in [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"), ("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] { + out = out.replacingOccurrences(of: entity, with: text, options: .caseInsensitive) + } + return out + } + + private static func resolve(_ href: String, against page: URL) -> URL? { + let href = href.trimmingCharacters(in: .whitespacesAndNewlines) + guard !href.isEmpty, let url = URL(string: href, relativeTo: page)?.absoluteURL, + isWeb(url.scheme), url.host != nil + else { return nil } + return url + } + + /// `/favicon.ico` at the page's origin: its scheme, host and port, nothing else. + private static func favicon(of page: URL) -> URL? { + guard isWeb(page.scheme), let host = page.host else { return nil } + var components = URLComponents() + components.scheme = page.scheme?.lowercased() + components.host = host + components.port = page.port + components.path = "/favicon.ico" + return components.url + } + + private static func isWeb(_ scheme: String?) -> Bool { + let scheme = scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + // MARK: Encoding + + /// What a candidate's bytes came to. + enum Encoded: Equatable { + /// The icon, within the bounds. + case icon(String) + /// ImageIO could not open them (or they are over `maxDownloadBytes`): the next + /// candidate is tried. + case undecodable + /// They opened, but the PNG is over `maxBytes` at 32 by 32 as well. The first + /// candidate that decodes decides, so there is no icon and no further candidate + /// is tried, as on every client (docs/e2ee.md, "Icons"). + case tooLarge + } + + static func encode(_ data: Data) -> Encoded { + guard data.count <= maxDownloadBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let index = bestFrame(of: source), + let image = CGImageSourceCreateImageAtIndex(source, index, nil) + else { return .undecodable } + for side in [maxSide, fallbackSide] { + guard let png = png(of: image, fitting: side) else { return .undecodable } + if png.count <= maxBytes { return .icon(png.base64EncodedString()) } + } + return .tooLarge + } + + /// The frame to shrink, by its longer side: the smallest of at least `maxSide`, so an + /// ICO's 64-pixel frame, drawn for that size, wins over its 256 one shrunk; else the + /// largest there is. The first wins a tie, which takes an animated GIF's first frame. + private static func bestFrame(of source: CGImageSource) -> Int? { + var best: (index: Int, side: Int)? + for index in 0..<CGImageSourceGetCount(source) { + guard let side = longerSide(source, index), side <= maxSourceSide else { continue } + guard let current = best else { + best = (index, side) + continue + } + let better = side >= maxSide + ? current.side < maxSide || side < current.side + : current.side < maxSide && side > current.side + if better { best = (index, side) } + } + return best?.index + } + + private static func pixelSize(_ source: CGImageSource, _ index: Int) -> (width: Int, height: Int)? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any], + let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue, + let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, + width > 0, height > 0 + else { return nil } + return (width, height) + } + + private static func longerSide(_ source: CGImageSource, _ index: Int) -> Int? { + pixelSize(source, index).map { max($0.width, $0.height) } + } + + /// `image` scaled down to fit `side` by `side` with its aspect kept, never up, as PNG. + /// Drawn into a context of exactly the target size rather than through ImageIO's + /// thumbnails, whose handling of an image already smaller than the limit is not + /// documented. Drawn in sRGB, so an icon in another color space (a CMYK JPEG, a P3 PNG) + /// comes out as every platform will show it; ImageIO marks that with a short `sRGB` + /// chunk, not a whole ICC profile, so it costs a few bytes of the 4096. + private static func png(of image: CGImage, fitting side: Int) -> Data? { + let scale = min(1, Double(side) / Double(max(image.width, image.height))) + let width = max(1, Int((Double(image.width) * scale).rounded())) + let height = max(1, Int((Double(image.height) * scale).rounded())) + guard let sRGB = CGColorSpace(name: CGColorSpace.sRGB), let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let scaled = context.makeImage() else { return nil } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, scaled, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return data as Data + } + + // MARK: Showing + + /// The image to draw for a push's `icon`, or nil when it is not a PNG within the + /// bounds, which draws the placeholder. Decoded once per icon and kept, failures too: a + /// row's body is evaluated far more often than its push changes. + static func image(_ icon: String) -> CGImage? { + let key = icon as NSString + if let decoded = cache.object(forKey: key) { return decoded.image } + let image = decode(icon) + cache.setObject(Decoded(image), forKey: key) + return image + } + + /// Opens an `icon` as the spec bounds it. Only PNG, and only up to 64 by 64, checked + /// before any pixel is decoded: whatever wrote the envelope, a small file must not + /// become a large image in a list of them. + static func decode(_ icon: String) -> CGImage? { + guard let data = Data(base64Encoded: icon), data.count <= maxBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetType(source) as String? == UTType.png.identifier, + let size = pixelSize(source, 0), size.width <= maxSide, size.height <= maxSide + else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + private final class Decoded { + let image: CGImage? + init(_ image: CGImage?) { self.image = image } + } + + private static let cache: NSCache<NSString, Decoded> = { + let cache = NSCache<NSString, Decoded>() + cache.countLimit = 500 + return cache + }() +} diff --git a/apps/macos/PushThat/Services/PushDocuments.swift b/apps/macos/PushThat/Services/PushDocuments.swift index b5f2508..722d2d1 100644 --- a/apps/macos/PushThat/Services/PushDocuments.swift +++ b/apps/macos/PushThat/Services/PushDocuments.swift @@ -137,27 +137,37 @@ enum PushDocuments { // MARK: - Titles - /// The scraped title written back to an encrypted URL push: exactly `enc` and + /// The scraped title and icon written back to an encrypted URL push: exactly `enc` and /// `searchTokens` (`isEncryptedRetitle` in the rules), the URL and ext as they were, the - /// new title under a fresh nonce. Only under the push's own key version, and only when - /// that is the one this device holds: a push made under another key is not ours to - /// rewrite. Last write wins. - static func retitle( - firestore: Firestore, - keys: PushKeys, - pushId: String, - userId: String, - keyVersion: Int?, - url: String, - ext: String, - title: String - ) async throws { - guard keyVersion == keys.keyVersion, userId == keys.uid else { return } - let sealed = try PushContent.url(url: url, title: title, ext: ext).seal(keys: keys, pushId: pushId) - try await firestore.collection("pushes").document(pushId).updateData([ - "enc": sealed.enc.firestoreData, - "searchTokens": sealed.searchTokens, - ]) + /// title and icon under a fresh nonce, by `PushContent.rescraped`'s rules. Only a push + /// this device can open: one under another key version is not ours to rewrite. + /// + /// Written over the push as it is at the write, in a transaction, not as the list read + /// it before the scrape. The scrape takes seconds, and another device scraping the same + /// push may write meanwhile. With titles alone that did no harm, since both wrote the + /// same title; with icons the writes differ (one device's decoder can open an icon + /// another's refuses), and a title-only write landing after a title-and-icon one dropped + /// the icon for good, since a titled push is not scraped again. + static func retitle(firestore: Firestore, keys: PushKeys, pushId: String, title: String?, icon: String?) async throws { + let ref = firestore.collection("pushes").document(pushId) + _ = try await firestore.runTransaction { transaction, errorPointer in + do { + let data = try transaction.getDocument(ref).data() ?? [:] + guard let userId = data["userId"] as? String, + let type = data["type"] as? String, + let enc = E2eeCrypto.Envelope(firestore: data["enc"]), + let current = PushContent.open( + keys: keys, userId: userId, pushId: pushId, type: type, + keyVersion: (data["keyVersion"] as? NSNumber)?.intValue, enc: enc + ), + let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: pushId) + else { return nil } + transaction.updateData(["enc": sealed.enc.firestoreData, "searchTokens": sealed.searchTokens], forDocument: ref) + } catch { + errorPointer?.pointee = error as NSError + } + return nil + } } // MARK: - Errors diff --git a/apps/macos/PushThat/Services/TitleScraperService.swift b/apps/macos/PushThat/Services/TitleScraperService.swift index e953e70..0e2dcdb 100644 --- a/apps/macos/PushThat/Services/TitleScraperService.swift +++ b/apps/macos/PushThat/Services/TitleScraperService.swift @@ -1,11 +1,12 @@ import Foundation import FirebaseFirestore -/// Fills in the title of an encrypted URL push from the page itself (docs/e2ee.md, -/// "Titles"). No server fetches it, since a server that fetched the URL would know it: a -/// push goes out with an empty title and a native device writes the title back, for its -/// own pushes and for one it received untitled (a web push, which has no way to read a -/// cross-origin page). +/// Fills in the title and the icon of an encrypted URL push from the page itself +/// (docs/e2ee.md, "Titles" and "Icons"). No server fetches them, since a server that +/// fetched the URL would know it: a push goes out with an empty title and a native device +/// writes the title back, for its own pushes and for one it received untitled (a web push, +/// which has no way to read a cross-origin page). The icon comes in the same pass, so that +/// no device has to ask anyone for it in order to draw the list. /// /// The app calls it for every untitled URL push the list shows, whoever made it, which /// covers the share extension's pushes (the extension is gone before a page could answer) @@ -20,40 +21,56 @@ actor TitleScraperService { private init() {} - /// Scrapes the page's title and rewrites the push's envelope with it: exactly `enc` and - /// `searchTokens`, only under the push's own key version (`PushDocuments.retitle`). A - /// failure is logged; the push keeps showing its URL. - func scrapeAndUpdate(pushId: String, userId: String, keyVersion: Int?, url: String, ext: String, keys: PushKeys) async { + /// Scrapes the page and rewrites the push's envelope with what it found + /// (`PushDocuments.retitle`), only under the push's own key version. A push that already + /// has an icon does not fetch another, since it keeps the one it has either way. A + /// failure is logged; the push keeps showing what it showed. + func scrapeAndUpdate(pushId: String, keyVersion: Int?, url: String, hasIcon: Bool, keys: PushKeys) async { guard keyVersion == keys.keyVersion, attempted.insert(pushId).inserted else { return } - guard let title = await scrapeTitle(from: url), !title.isEmpty else { return } + guard let page = await scrape(from: url, withIcon: !hasIcon), page.title != nil || page.icon != nil else { return } do { - try await PushDocuments.retitle( - firestore: db, keys: keys, pushId: pushId, userId: userId, - keyVersion: keyVersion, url: url, ext: ext, title: title - ) + try await PushDocuments.retitle(firestore: db, keys: keys, pushId: pushId, title: page.title, icon: page.icon) } catch { print("Failed to update title: \(type(of: error))") } } - /// How much of a page is read for its title. + /// How much of a page is read for its title and its icon links. private static let maxPageBytes = 256 * 1024 - /// Scrape the title from a URL - func scrapeTitle(from urlString: String) async -> String? { + /// How long the icon candidates are tried for, after the page. + private static let iconBudget: TimeInterval = 30 + + /// The session every request of a scrape goes through. `timeoutInterval` bounds only a + /// gap between bytes, so a server sending one byte every few seconds held a request open + /// indefinitely, and the icon adds up to nine requests to the page's. The resource + /// timeout bounds each one start to finish, as `callTimeout` does on Android. + private static let session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForResource = 15 + return URLSession(configuration: configuration) + }() + + /// Sent with the page's request and its icons' alike, so the site sees one browser. + private static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" + + /// The page's title and, when `withIcon`, its icon (`PushIcon`), or nil when the URL is + /// not an HTML page that answered. The title is nil when the page has none this reads. + private func scrape(from urlString: String, withIcon: Bool) async -> (title: String?, icon: String?)? { guard let url = URL(string: urlString) else { return nil } do { var request = URLRequest(url: url) - request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", forHTTPHeaderField: "User-Agent") + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 // Streamed, not `data(for:)`: the pushes left untitled are mostly links to files // (PDFs, video, archives), which have no title to find, and the list asks for // every one it shows. Only an HTML page is read, and only its first 256 KiB, where // the <title> is; `timeoutInterval` bounds a stall, not a download, so without the - // cap a large file would be pulled into memory on every launch. - let (bytes, response) = try await URLSession.shared.bytes(for: request) + // cap a large file would be pulled into memory on every launch. Nor is an icon + // looked for on anything but a page: a link to a file shows its type's icon. + let (bytes, response) = try await Self.session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, @@ -69,16 +86,18 @@ actor TitleScraperService { // The header's charset, else UTF-8, decoded strictly: a page in an encoding this // cannot read gets no title, rather than a garbled one written into the push for // every device. Up to three bytes are dropped first, the most the cap can cut from - // a character, so a page cut mid-character still reads. + // a character, so a page cut mid-character still reads. Such a page has no icon + // links to read either, but its origin's /favicon.ico is still a candidate. let encoding = httpResponse.textEncodingName .map { CFStringConvertIANACharSetNameToEncoding($0 as CFString) } .flatMap { $0 == kCFStringEncodingInvalidId ? nil : String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding($0)) } ?? .utf8 - guard let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first else { - return nil - } + let html = (0...3).lazy.compactMap({ String(data: data.dropLast($0), encoding: encoding) }).first ?? "" - return extractTitle(from: html) + // Icons resolve against the URL the page came from after redirects, as a + // browser resolves them, not against the one that was pushed. + let icon = withIcon ? await fetchIcon(from: PushIcon.candidates(html: html, pageURL: httpResponse.url ?? url)) : nil + return (extractTitle(from: html), icon) } catch { // The error's type only: a URLError carries the page's full URL. print("Failed to scrape title: \(type(of: error))") @@ -86,6 +105,51 @@ actor TitleScraperService { } } + /// The icon made from the first candidate that ImageIO opens (`PushIcon.encode`), or + /// nil when none opens or the one that does is too large even at 32 by 32. One at a + /// time, best first, so a page whose first candidate is good costs one request more + /// than its title. + private func fetchIcon(from candidates: [URL]) async -> String? { + // A budget for the lot, checked between candidates: each request is bounded by the + // session, and nine of them in a row still made a long scrape. + let deadline = Date().addingTimeInterval(Self.iconBudget) + for candidate in candidates { + guard Date() < deadline else { return nil } + guard let data = await download(candidate) else { continue } + switch PushIcon.encode(data) { + case let .icon(icon): return icon + case .tooLarge: return nil + case .undecodable: continue + } + } + return nil + } + + /// A candidate's bytes, or nil when it does not answer 200 or is over + /// `PushIcon.maxDownloadBytes`. Streamed as the page is, so a large file is given up at + /// the cap rather than pulled into memory first. + private func download(_ url: URL) async -> Data? { + var request = URLRequest(url: url) + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 10 + do { + let (bytes, response) = try await Self.session.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, + httpResponse.expectedContentLength <= Int64(PushIcon.maxDownloadBytes) + else { return nil } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count > PushIcon.maxDownloadBytes { return nil } + } + return data + } catch { + // The error's type only, as for the page: a URLError carries the full URL. + print("Failed to fetch icon: \(type(of: error))") + return nil + } + } + /// Extract title from HTML using regex /// Note: For production, consider using SwiftSoup for proper HTML parsing private func extractTitle(from html: String) -> String? { diff --git a/apps/macos/PushThat/Utilities/FileTypeIcons.swift b/apps/macos/PushThat/Utilities/FileTypeIcons.swift index 61cfb61..7146272 100644 --- a/apps/macos/PushThat/Utilities/FileTypeIcons.swift +++ b/apps/macos/PushThat/Utilities/FileTypeIcons.swift @@ -1,10 +1,15 @@ import Foundation -/// Maps file extensions to icons hosted on PushThat CDN -/// Matches the Android SUPPORTED_FILE_ICONS list exactly +/// The file types with an icon of their own, drawn from the app's asset catalog as +/// `file-<ext>` (copies of `apps/web/public/files/<ext>.png`). Matches the Android list. +/// +/// They were loaded from pushthat.link/files, which put the type of every encrypted file a +/// list showed in the hosting logs (docs/e2ee.md, "Icons"); shipped in the app, drawing +/// one asks nobody anything. struct FileTypeIcons { - /// Set of supported file extensions that have custom icons + /// The extensions that have an icon. The one list: an extension added here needs its + /// `file-<ext>` image set in Assets.xcassets, in both apps. static let supported: Set<String> = [ "pproj", "zip", "indd", "csv", "css", "pdf", "aac", "png", "xls", "avi", "txt", "mp3", "tiff", "psd", "jpg", "jpeg", @@ -12,11 +17,10 @@ struct FileTypeIcons { "xml", "apk" ] - /// Returns the URL for a file type icon, or nil if not supported - static func iconURL(for fileExtension: String) -> URL? { - let ext = fileExtension.lowercased() - guard supported.contains(ext) else { return nil } - return URL(string: "https://pushthat.link/files/\(ext).png") + /// The asset catalog name of the icon for `fileExtension`, or nil when it has none. + static func assetName(for fileExtension: String?) -> String? { + guard let ext = fileExtension?.lowercased(), supported.contains(ext) else { return nil } + return "file-\(ext)" } /// Returns a SF Symbol name appropriate for the file type diff --git a/apps/macos/PushThat/ViewModels/PushListViewModel.swift b/apps/macos/PushThat/ViewModels/PushListViewModel.swift index 59ab556..51c1ef0 100644 --- a/apps/macos/PushThat/ViewModels/PushListViewModel.swift +++ b/apps/macos/PushThat/ViewModels/PushListViewModel.swift @@ -259,18 +259,19 @@ class PushListViewModel: ObservableObject { showError = true } - /// Untitled URL pushes get their page's title, whichever device made them + /// Untitled URL pushes get their page's title and icon, whichever device made them /// (`TitleScraperService`). In the background: the list does not wait for any page. + /// A titled push is not read again for an icon (docs/e2ee.md, "Icons"), so a push + /// from before icons keeps the placeholder rather than every list asking every site. private func scrapeUntitled(_ items: [PushItem], keys: PushKeys) { for item in items where item.type == .url && item.title.isEmpty { guard case .encrypted = item.source, let id = item.id else { continue } let url = item.url - let ext = item.ext ?? "" - let userId = item.userId + let hasIcon = !(item.icon ?? "").isEmpty let keyVersion = item.keyVersion Task.detached(priority: .utility) { await TitleScraperService.shared.scrapeAndUpdate( - pushId: id, userId: userId, keyVersion: keyVersion, url: url, ext: ext, keys: keys + pushId: id, keyVersion: keyVersion, url: url, hasIcon: hasIcon, keys: keys ) } } diff --git a/apps/macos/PushThat/Views/PushRowView.swift b/apps/macos/PushThat/Views/PushRowView.swift index 4fce0e4..1125434 100644 --- a/apps/macos/PushThat/Views/PushRowView.swift +++ b/apps/macos/PushThat/Views/PushRowView.swift @@ -16,7 +16,7 @@ struct PushRowView: View { // Swivel checkbox / favicon SwivelCheckView( isChecked: isSelected, - imageURL: faviconURL, + image: icon, fallbackIcon: item.type == .file ? "doc.fill" : "link.circle.fill" ) .frame(width: 40, height: 40) @@ -123,18 +123,23 @@ struct PushRowView: View { return date.relativeFormatted() } - private var faviconURL: URL? { - // A locked push shows the placeholder: there is no URL or name to show one for. + /// What the row draws, all of it already on the device: nothing is fetched to draw a + /// row (docs/e2ee.md, "Icons"). A URL push draws the icon that came in it, else the + /// icon for its `ext` from the asset catalog (a link to a file has no page to take an + /// icon from); a file push draws its type's icon. Nil draws the placeholder a locked + /// push draws, also for an icon that does not decode. The order is the same on every + /// client. + /// + /// This was Google's favicon service for every link, which learned the host of each + /// pushed link whenever the list was drawn, and pushthat.link for file types, whose logs + /// learned the type of each encrypted file. + private var icon: Image? { + // A locked push shows the placeholder: there is nothing it can be known by. if item.isLocked { return nil } - - // Check for file extension icon first - if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()) { - return URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") + if item.type == .url, let icon = item.icon, let image = PushIcon.image(icon) { + return Image(decorative: image, scale: 1) } - - // Fall back to Google favicon service - guard let urlHost = URL(string: item.url)?.host else { return nil } - return URL(string: "https://www.google.com/s2/favicons?domain=\(urlHost)&sz=256") + return FileTypeIcons.assetName(for: item.ext).map { Image($0) } } } diff --git a/apps/macos/PushThat/Views/SwivelCheckView.swift b/apps/macos/PushThat/Views/SwivelCheckView.swift index 18740d8..12fcaf1 100644 --- a/apps/macos/PushThat/Views/SwivelCheckView.swift +++ b/apps/macos/PushThat/Views/SwivelCheckView.swift @@ -2,9 +2,12 @@ import SwiftUI /// Custom 3D flip checkbox that shows a favicon on the front and a checkmark on the back /// Matches the Android SwivelCheckView behavior +/// +/// It takes an image the caller already holds, not a URL: drawing a row fetches nothing +/// (docs/e2ee.md, "Icons"). `fallbackIcon` is drawn when there is none. struct SwivelCheckView: View { let isChecked: Bool - let imageURL: URL? + let image: Image? var fallbackIcon: String = "link.circle.fill" @State private var rotation: Double = 0 @@ -40,23 +43,12 @@ struct SwivelCheckView: View { private var frontSide: some View { Group { - if let url = imageURL { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - ProgressView() - .frame(width: 40, height: 40) - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 8)) - case .failure: - fallbackIconView - @unknown default: - fallbackIconView - } - } + if let image { + image + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) } else { fallbackIconView } @@ -83,27 +75,17 @@ struct SwivelCheckView: View { #Preview { VStack(spacing: 20) { - // Unchecked state - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + // Unchecked state, with no icon: the placeholder + SwivelCheckView(isChecked: false, image: nil) + .frame(width: 40, height: 40) // Checked state - SwivelCheckView( - isChecked: true, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: true, image: nil) + .frame(width: 40, height: 40) // With file icon - SwivelCheckView( - isChecked: false, - imageURL: URL(string: "https://pushthat.link/files/pdf.png"), - fallbackIcon: "doc.fill" - ) - .frame(width: 40, height: 40) + SwivelCheckView(isChecked: false, image: Image("file-pdf"), fallbackIcon: "doc.fill") + .frame(width: 40, height: 40) // Interactive demo SwivelCheckViewDemo() @@ -116,11 +98,8 @@ struct SwivelCheckViewDemo: View { var body: some View { VStack { - SwivelCheckView( - isChecked: isChecked, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=apple.com&sz=256") - ) - .frame(width: 64, height: 64) + SwivelCheckView(isChecked: isChecked, image: Image("file-zip")) + .frame(width: 64, height: 64) .onTapGesture { isChecked.toggle() } diff --git a/apps/macos/PushThatTests/E2eeVectorTests.swift b/apps/macos/PushThatTests/E2eeVectorTests.swift index 0ae6619..2792582 100644 --- a/apps/macos/PushThatTests/E2eeVectorTests.swift +++ b/apps/macos/PushThatTests/E2eeVectorTests.swift @@ -1,4 +1,6 @@ import XCTest +import ImageIO +import UniformTypeIdentifiers /// Checks `E2eeCrypto` and `SearchTokenGenerator` against vectors 1 to 6 of /// `packages/push-test/vectors.json` (docs/e2ee.md, "Test vectors"), byte for byte. The file is copied into the test bundle @@ -218,6 +220,7 @@ final class E2eeVectorTests: XCTestCase { let contents: [PushContent] = [ .url(url: "https://example.com/a", title: "", ext: ""), .url(url: "https://example.com/b.pdf", title: "Example \u{e9}", ext: "pdf"), + .url(url: "https://example.com/c", title: "Example", ext: "", icon: "iVBORw0KGgo="), .file(PushFile(name: "Report (final) v2.pdf", mime: "application/pdf", size: 3_145_729, ext: "pdf", fk: E2eeCrypto.newFileKey(), np: E2eeCrypto.newFilePrefix())), .file(PushFile(name: "old.txt", mime: "", size: 0, ext: "txt", fk: nil, np: nil)), @@ -245,7 +248,7 @@ final class E2eeVectorTests: XCTestCase { let long = PushContent.url(url: "https://example.com", title: String(repeating: "t", count: 5000), ext: "") let opened = try XCTUnwrap(PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: PreparedPush(content: long, keys: keys, pushId: "p").enc)) - guard case let .url(_, title, _) = opened else { return XCTFail("not a URL push") } + guard case let .url(_, title, _, _) = opened else { return XCTFail("not a URL push") } XCTAssertEqual(title.count, PushContent.maxTitleLength) let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "") @@ -254,6 +257,69 @@ final class E2eeVectorTests: XCTestCase { } } + /// `icon` is optional: absent and "" both read as none, and none is not written, so a + /// push without one is the same plaintext as before icons. + func testURLIconIsOptional() throws { + let without = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "").json()) as? [String: Any]) + XCTAssertNil(without["icon"]) + let empty = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "").json()) as? [String: Any]) + XCTAssertNil(empty["icon"]) + let with = try XCTUnwrap(JSONSerialization.jsonObject(with: PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").json()) as? [String: Any]) + XCTAssertEqual(with["icon"] as? String, "AAAA") + + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":""}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: nil)) + XCTAssertEqual(try PushContent(json: Data(#"{"url":"https://example.com","title":"T","ext":"","icon":"AAAA"}"#.utf8), type: "url"), + .url(url: "https://example.com", title: "T", ext: "", icon: "AAAA")) + // The icon is not searched. + XCTAssertEqual(PushContent.url(url: "https://example.com", title: "T", ext: "", icon: "AAAA").searchText, "T https://example.com") + } + + /// What a scrape writes back (`PushContent.rescraped`), opened again to compare. + private func rescrape(_ current: PushContent, title: String?, icon: String?) throws -> PushContent?? { + let keys = try vectorKeys() + guard let sealed = try PushContent.rescraped(current, title: title, icon: icon, keys: keys, pushId: "p") else { return .some(nil) } + return PushContent.open(keys: keys, userId: keys.uid, pushId: "p", type: "url", keyVersion: 1, enc: sealed.enc) + } + + /// A rewrite happens for a new title or a first icon, keeps the icon a push has, and + /// does not happen when the scrape found nothing new. + func testARescrapeWritesOnlyANewTitleOrAFirstIcon() throws { + let url = "https://example.com" + let untitled = PushContent.url(url: url, title: "", ext: "", icon: nil) + let titled = PushContent.url(url: url, title: "T", ext: "", icon: "AAAA") + + XCTAssertEqual(try rescrape(untitled, title: "T", icon: "AAAA"), .url(url: url, title: "T", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: "AAAA"), .url(url: url, title: "", ext: "", icon: "AAAA")) + XCTAssertEqual(try rescrape(untitled, title: "T", icon: nil), .url(url: url, title: "T", ext: "", icon: nil)) + // An icon the push has is kept, and not replaced by another. + XCTAssertEqual(try rescrape(titled, title: "U", icon: "BBBB"), .url(url: url, title: "U", ext: "", icon: "AAAA")) + // Nothing new: the same title, a blank one, or an icon where there is one already. + XCTAssertEqual(try rescrape(titled, title: "T", icon: "BBBB"), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: " ", icon: nil), .some(nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: ""), .some(nil)) + } + + /// When the envelope with the icon would be over the bound, a new title is written alone, + /// and an icon that was the only new thing is no reason to write. + func testARescrapeDropsAnIconThatDoesNotFit() throws { + let icon = String(repeating: "A", count: 5464) + let long = "https://example.com/" + String(repeating: "a", count: 9000) + let untitled = PushContent.url(url: long, title: "", ext: "", icon: nil) + + XCTAssertEqual(try rescrape(untitled, title: "T", icon: icon), .url(url: long, title: "T", ext: "", icon: nil)) + XCTAssertEqual(try rescrape(untitled, title: nil, icon: icon), .some(nil)) + XCTAssertEqual(try rescrape(.url(url: "https://example.com", title: "", ext: "", icon: nil), title: "T", icon: icon), + .url(url: "https://example.com", title: "T", ext: "", icon: icon)) + + let absurd = PushContent.url(url: "https://example.com/" + String(repeating: "a", count: 13_000), title: "", ext: "", icon: nil) + XCTAssertThrowsError(try rescrape(absurd, title: "T", icon: icon)) { error in + XCTAssertEqual(error as? PushContent.ContentError, .tooLarge) + } + } + // MARK: - Vector 4: the blind index func testSearchVector() throws { @@ -479,3 +545,135 @@ final class E2eeVectorTests: XCTestCase { XCTAssertEqual(PushFile.ext(of: "README"), "") } } + +/// The icon's capture and display (docs/e2ee.md, "Icons"). No network: the candidates are +/// read from a string and the images are made here. +final class PushIconTests: XCTestCase { + private let page = URL(string: "https://www.example.com:8443/dir/page?q=1#top")! + + func testCandidateOrder() { + let html = """ + <html><head> + <LINK href='/a.png' REL="Shortcut Icon"> + <link rel=icon type="image/svg+xml" href="/vector"> + <link rel="icon" href="logo.SVG?v=1"> + <link data-x="a>b" rel="apple-touch-icon" href="https://cdn.example.com/t.png?a=1&b=2"> + <link rel="apple-touch-icon-precomposed" href="javascript:void(0)"> + <link rel="stylesheet" href="s.css"> + <link rel="icon" href="//other.example.com/i.ico" /> + <linkx rel="icon" href="/not-a-link.png"> + <link rel="icon" href="/a.png"> + <link rel="icon" href="data:image/png;base64,AAAA"> + """ + XCTAssertEqual(PushIcon.candidates(html: html, pageURL: page).map(\.absoluteString), [ + "https://cdn.example.com/t.png?a=1&b=2", + "https://www.example.com:8443/a.png", + "https://other.example.com/i.ico", + "https://www.example.com:8443/favicon.ico", + ]) + } + + func testCandidatesResolveRelativeToThePage() { + XCTAssertEqual(PushIcon.candidates(html: #"<link href="icons/x.ico" rel="icon">"#, pageURL: page).map(\.absoluteString), [ + "https://www.example.com:8443/dir/icons/x.ico", + "https://www.example.com:8443/favicon.ico", + ]) + // A <base href> is not followed. + XCTAssertEqual(PushIcon.candidates(html: #"<base href="https://elsewhere.example.com/"><link rel="icon" href="x.ico">"#, pageURL: page).first?.absoluteString, + "https://www.example.com:8443/dir/x.ico") + // No links: only the origin's favicon. Not http: nothing. + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "http://example.com/a/b")!).map(\.absoluteString), ["http://example.com/favicon.ico"]) + XCTAssertEqual(PushIcon.candidates(html: "", pageURL: URL(string: "ftp://example.com/a")!), []) + } + + func testAtMostEightLinksThenTheFavicon() { + let html = (1...20).map { #"<link rel="icon" href="/\#($0).png">"# }.joined() + let candidates = PushIcon.candidates(html: html, pageURL: page).map(\.path) + XCTAssertEqual(candidates, (1...8).map { "/\($0).png" } + ["/favicon.ico"]) + } + + func testEncodeScalesDownKeepingAspect() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 200, height: 100)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [64, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + func testEncodeNeverScalesUp() throws { + let image = try XCTUnwrap(PushIcon.decode(try XCTUnwrap(icon(PushIcon.encode(png(width: 16, height: 16)))))) + XCTAssertEqual([image.width, image.height], [16, 16]) + } + + /// Gray noise is over 4096 bytes at 64 pixels and under it at 32. + func testEncodeFallsBackTo32() throws { + let icon = try XCTUnwrap(icon(PushIcon.encode(png(width: 64, height: 64, noise: .gray)))) + let image = try XCTUnwrap(PushIcon.decode(icon)) + XCTAssertEqual([image.width, image.height], [32, 32]) + XCTAssertLessThanOrEqual(try XCTUnwrap(Data(base64Encoded: icon)).count, PushIcon.maxBytes) + } + + /// Noise in every channel, alpha too, is over 4096 bytes even at 32 pixels. + func testEncodeGivesUpOverTheBound() { + XCTAssertEqual(PushIcon.encode(png(width: 48, height: 48, noise: .everyChannel)), .tooLarge) + } + + func testEncodeRefusesWhatDoesNotDecode() { + XCTAssertEqual(PushIcon.encode(Data("<html>not an image</html>".utf8)), .undecodable) + XCTAssertEqual(PushIcon.encode(Data(count: PushIcon.maxDownloadBytes + 1)), .undecodable) + } + + /// Display opens only a PNG within 64 by 64. + func testDecodeChecksTheBounds() throws { + XCTAssertNotNil(PushIcon.decode(png(width: 64, height: 64).base64EncodedString())) + XCTAssertNil(PushIcon.decode(png(width: 65, height: 10).base64EncodedString())) + XCTAssertNil(PushIcon.decode(jpeg(width: 16, height: 16).base64EncodedString())) + XCTAssertNil(PushIcon.decode("not base64")) + XCTAssertNil(PushIcon.decode("")) + } + + // MARK: - Images + + private func icon(_ encoded: PushIcon.Encoded) -> String? { + if case let .icon(icon) = encoded { return icon } + return nil + } + + private enum Noise { case none, gray, everyChannel } + + /// A test image: flat gray, or noise from a fixed seed, so every run makes the same one. + private func image(width: Int, height: Int, noise: Noise) -> CGImage { + let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let pixels = context.data!.assumingMemoryBound(to: UInt8.self) + var seed: UInt32 = 1 + for i in 0..<(width * height) { + seed = seed &* 1_664_525 &+ 1_013_904_223 + let byte = { (shift: UInt32) in UInt8(truncatingIfNeeded: seed >> shift) } + switch noise { + case .none: + pixels[i * 4] = 128; pixels[i * 4 + 1] = 128; pixels[i * 4 + 2] = 128; pixels[i * 4 + 3] = 255 + case .gray: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(24); pixels[i * 4 + 2] = byte(24); pixels[i * 4 + 3] = 255 + case .everyChannel: + pixels[i * 4] = byte(24); pixels[i * 4 + 1] = byte(16); pixels[i * 4 + 2] = byte(8); pixels[i * 4 + 3] = max(byte(4), 1) + } + } + return context.makeImage()! + } + + private func png(width: Int, height: Int, noise: Noise = .none) -> Data { + encoded(image(width: width, height: height, noise: noise), as: .png) + } + + private func jpeg(width: Int, height: Int) -> Data { + encoded(image(width: width, height: height, noise: .none), as: .jpeg) + } + + private func encoded(_ image: CGImage, as type: UTType) -> Data { + let data = NSMutableData() + let destination = CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(destination, image, nil) + CGImageDestinationFinalize(destination) + return data as Data + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 5013451..4a644cf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "preview": "vite preview", "preview:firebase": "firebase emulators:start", "test:rules": "firebase emulators:exec --project demo-pushthat --only firestore,storage \"node --test --test-concurrency=1 tests/*.test.mjs\"", - "test:keys": "node --test tests/e2ee.vectors.mjs tests/keyDecisions.mjs tests/pushRefusal.mjs tests/viewable.mjs", + "test:keys": "node --test tests/e2ee.vectors.mjs tests/keyDecisions.mjs tests/pushRefusal.mjs tests/viewable.mjs tests/pageIcon.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "spellcheck": "cspell -c ../../cspell.json --gitignore \"**/*.{js,json,jsx,md,ts,tsx}\"" diff --git a/apps/web/public/files/aac.svg b/apps/web/src/assets/file-icons/aac.svg similarity index 100% rename from apps/web/public/files/aac.svg rename to apps/web/src/assets/file-icons/aac.svg diff --git a/apps/web/public/files/ai.svg b/apps/web/src/assets/file-icons/ai.svg similarity index 100% rename from apps/web/public/files/ai.svg rename to apps/web/src/assets/file-icons/ai.svg diff --git a/apps/web/public/files/apk.svg b/apps/web/src/assets/file-icons/apk.svg similarity index 100% rename from apps/web/public/files/apk.svg rename to apps/web/src/assets/file-icons/apk.svg diff --git a/apps/web/public/files/avi.svg b/apps/web/src/assets/file-icons/avi.svg similarity index 100% rename from apps/web/public/files/avi.svg rename to apps/web/src/assets/file-icons/avi.svg diff --git a/apps/web/public/files/css.svg b/apps/web/src/assets/file-icons/css.svg similarity index 100% rename from apps/web/public/files/css.svg rename to apps/web/src/assets/file-icons/css.svg diff --git a/apps/web/public/files/csv.svg b/apps/web/src/assets/file-icons/csv.svg similarity index 100% rename from apps/web/public/files/csv.svg rename to apps/web/src/assets/file-icons/csv.svg diff --git a/apps/web/public/files/doc.svg b/apps/web/src/assets/file-icons/doc.svg similarity index 100% rename from apps/web/public/files/doc.svg rename to apps/web/src/assets/file-icons/doc.svg diff --git a/apps/web/public/files/flac.svg b/apps/web/src/assets/file-icons/flac.svg similarity index 100% rename from apps/web/public/files/flac.svg rename to apps/web/src/assets/file-icons/flac.svg diff --git a/apps/web/public/files/gif.svg b/apps/web/src/assets/file-icons/gif.svg similarity index 100% rename from apps/web/public/files/gif.svg rename to apps/web/src/assets/file-icons/gif.svg diff --git a/apps/web/public/files/html.svg b/apps/web/src/assets/file-icons/html.svg similarity index 100% rename from apps/web/public/files/html.svg rename to apps/web/src/assets/file-icons/html.svg diff --git a/apps/web/public/files/indd.svg b/apps/web/src/assets/file-icons/indd.svg similarity index 100% rename from apps/web/public/files/indd.svg rename to apps/web/src/assets/file-icons/indd.svg diff --git a/apps/web/public/files/jpeg.svg b/apps/web/src/assets/file-icons/jpeg.svg similarity index 100% rename from apps/web/public/files/jpeg.svg rename to apps/web/src/assets/file-icons/jpeg.svg diff --git a/apps/web/public/files/jpg.svg b/apps/web/src/assets/file-icons/jpg.svg similarity index 100% rename from apps/web/public/files/jpg.svg rename to apps/web/src/assets/file-icons/jpg.svg diff --git a/apps/web/public/files/js.svg b/apps/web/src/assets/file-icons/js.svg similarity index 100% rename from apps/web/public/files/js.svg rename to apps/web/src/assets/file-icons/js.svg diff --git a/apps/web/public/files/mov.svg b/apps/web/src/assets/file-icons/mov.svg similarity index 100% rename from apps/web/public/files/mov.svg rename to apps/web/src/assets/file-icons/mov.svg diff --git a/apps/web/public/files/mp3.svg b/apps/web/src/assets/file-icons/mp3.svg similarity index 100% rename from apps/web/public/files/mp3.svg rename to apps/web/src/assets/file-icons/mp3.svg diff --git a/apps/web/public/files/mp4.svg b/apps/web/src/assets/file-icons/mp4.svg similarity index 100% rename from apps/web/public/files/mp4.svg rename to apps/web/src/assets/file-icons/mp4.svg diff --git a/apps/web/public/files/pdf.svg b/apps/web/src/assets/file-icons/pdf.svg similarity index 100% rename from apps/web/public/files/pdf.svg rename to apps/web/src/assets/file-icons/pdf.svg diff --git a/apps/web/public/files/png.svg b/apps/web/src/assets/file-icons/png.svg similarity index 100% rename from apps/web/public/files/png.svg rename to apps/web/src/assets/file-icons/png.svg diff --git a/apps/web/public/files/pproj.svg b/apps/web/src/assets/file-icons/pproj.svg similarity index 100% rename from apps/web/public/files/pproj.svg rename to apps/web/src/assets/file-icons/pproj.svg diff --git a/apps/web/public/files/psd.svg b/apps/web/src/assets/file-icons/psd.svg similarity index 100% rename from apps/web/public/files/psd.svg rename to apps/web/src/assets/file-icons/psd.svg diff --git a/apps/web/public/files/tiff.svg b/apps/web/src/assets/file-icons/tiff.svg similarity index 100% rename from apps/web/public/files/tiff.svg rename to apps/web/src/assets/file-icons/tiff.svg diff --git a/apps/web/public/files/txt.svg b/apps/web/src/assets/file-icons/txt.svg similarity index 100% rename from apps/web/public/files/txt.svg rename to apps/web/src/assets/file-icons/txt.svg diff --git a/apps/web/public/files/xls.svg b/apps/web/src/assets/file-icons/xls.svg similarity index 100% rename from apps/web/public/files/xls.svg rename to apps/web/src/assets/file-icons/xls.svg diff --git a/apps/web/public/files/xml.svg b/apps/web/src/assets/file-icons/xml.svg similarity index 100% rename from apps/web/public/files/xml.svg rename to apps/web/src/assets/file-icons/xml.svg diff --git a/apps/web/public/files/zip.svg b/apps/web/src/assets/file-icons/zip.svg similarity index 100% rename from apps/web/public/files/zip.svg rename to apps/web/src/assets/file-icons/zip.svg diff --git a/apps/web/src/components/PushesList.tsx b/apps/web/src/components/PushesList.tsx index c6f1ff7..60256b9 100644 --- a/apps/web/src/components/PushesList.tsx +++ b/apps/web/src/components/PushesList.tsx @@ -1,4 +1,3 @@ -// cspell:ignore pproj indd flac import clsx from 'clsx'; import { getStorage } from 'firebase/storage'; import { Dispatch, SetStateAction, useState } from 'react'; @@ -16,43 +15,34 @@ interface PushesListProps { onSelectedPushesChange: Dispatch<SetStateAction<Set<string>>>; } -const ICONS = [ - 'pproj.svg', - 'zip.svg', - 'indd.svg', - 'csv.svg', - 'css.svg', - 'pdf.svg', - 'aac.svg', - 'png.svg', - 'xls.svg', - 'avi.svg', - 'txt.svg', - 'mp3.svg', - 'tiff.svg', - 'psd.svg', - 'jpg.svg', - 'jpeg.svg', - 'mov.svg', - 'html.svg', - 'ai.svg', - 'js.svg', - 'flac.svg', - 'mp4.svg', - 'gif.svg', - 'doc.svg', - 'xml.svg', - 'apk.svg', -]; - -const faviconOf = (url: string | undefined): string => { - let host = ''; - try { - host = url ? new URL(url).hostname : ''; - } catch (error) { - console.error('Error parsing URL:', error); - } - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=256`; +/** + * A file push's icon by its extension. Bundled, and small enough that Vite inlines each one + * as a data URL (`?inline` makes sure), so drawing the list asks no server anything: served + * from pushthat.link/files they put the type of every encrypted file a list showed in the + * hosting logs (docs/e2ee.md, Icons). The PNGs in `public/files` stay, for app versions + * from before this change, which still load them from there. + */ +// A Map, not an object: an extension comes from a file's name, and an object would answer +// `constructor` or `__proto__` with what it inherits. +const FILE_ICONS = new Map<string, string>( + Object.entries( + import.meta.glob<string>('../assets/file-icons/*.svg', { eager: true, query: '?inline', import: 'default' }) + ).map(([path, src]) => [path.slice(path.lastIndexOf('/') + 1, -'.svg'.length), src]) +); + +/** The placeholder for a push with no icon of its own, and for a locked one. */ +const PLACEHOLDER = '/pushthat-mono.svg'; + +/** + * What a row's icon shows: a URL push's page icon, else the icon for its type (a link to a + * PDF has no page to take an icon from), else the placeholder. Never the network: the page + * icon came with the push. Google's favicon service drew these until it was seen to learn + * every pushed host from every list (docs/e2ee.md, Icons). + */ +const iconSrc = (push: Push, broken: boolean): string => { + if (push.locked) return PLACEHOLDER; + if (push.icon && !broken) return `data:image/png;base64,${push.icon}`; + return (push.ext && FILE_ICONS.get(push.ext.toLowerCase())) || PLACEHOLDER; }; /** @@ -86,7 +76,10 @@ export default function PushesList({ selectedPushes, onSelectedPushesChange, }: PushesListProps) { - const [lowResImages, setLowResImages] = useState<Set<string>>(new Set()); + // Page icons that would not decode, which then draw the type's icon or the placeholder. + // By the icon, not the push, as the phones' caches are: a push whose icon changes while + // the list is open is drawn with its new one. + const [brokenIcons, setBrokenIcons] = useState<Set<string>>(new Set()); const [opening, setOpening] = useState<string | undefined>(undefined); // What opening a row has to say (a locked push, a file that did not decrypt), shown above // the list as the app shows its other errors, not in a browser dialog. @@ -130,21 +123,6 @@ export default function PushesList({ }); }; - const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>, pushId: string) => { - const image = event.currentTarget; - if (image.naturalHeight === 16) { - setLowResImages((prev) => new Set(prev).add(pushId)); - } - }; - - const getFallbackImageSrc = (push: Push, icon: string | undefined) => { - // If this push has a low-res image, use the PushThat logo as fallback - if (lowResImages.has(push.id)) { - return '/pushthat-mono.svg'; - } - return null; - }; - if (loading) { return <div className={styles.textContainer}>Loading...</div>; } @@ -166,12 +144,7 @@ export default function PushesList({ )} <ul className={styles.pushesList}> {pushes.map((push) => { - const icon = ICONS.find((icon) => push.ext === icon.split('.')[0]); const isSelected = selectedPushes.has(push.id); - // A locked push, or a file with no icon of its own, has no host to ask for a - // favicon (and an empty one would still ask Google). - const fallbackSrc = - push.locked || (!icon && !push.url) ? '/pushthat-mono.svg' : getFallbackImageSrc(push, icon); const content = ( <> <div @@ -180,21 +153,15 @@ export default function PushesList({ > {isSelected ? ( <CheckIcon /> - ) : fallbackSrc ? ( - <img src={fallbackSrc} alt="" className={styles.favicon} /> - ) : icon ? ( - <img - src={`/files/${icon}`} - alt="" - className={styles.favicon} - onLoad={(e) => handleImageLoad(e, push.id)} - /> ) : ( <img - src={faviconOf(push.url)} + src={iconSrc(push, !!push.icon && brokenIcons.has(push.icon))} alt="" className={styles.favicon} - onLoad={(e) => handleImageLoad(e, push.id)} + onError={() => { + const { icon } = push; + if (icon) setBrokenIcons((prev) => new Set(prev).add(icon)); + }} /> )} </div> diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 1d8379d..bccea27 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -15,7 +15,7 @@ export default function Privacy() { <h1>PushThat Privacy Policy</h1> - <p>Last updated: October 27, 2026</p> + <p>Last updated: November 12, 2026</p> <p> This Privacy Policy describes how your personal information is collected, used, and shared when you visit or @@ -25,13 +25,13 @@ export default function Privacy() { <h5>YOUR PUSHES ARE END-TO-END ENCRYPTED</h5> <p> - The links you push, their page titles, and the names, types and contents of the files you push (together, “Push - Content”) are encrypted on your device before they leave it, with a key that only your password, or your - recovery key, can unlock. They can be decrypted only on the devices you sign in to. We, and Google, which runs - the servers the App uses, hold Push Content only in that encrypted form and cannot read it, apart from pushes - made before end-to-end encryption that are still stored as they were sent (see Data Retention below). Your - recovery key is never sent to us or to Google, and your password only in the cases described below. The{' '} - <Link to="/security">Security</Link> page explains how this works and what it does not cover. + The links you push, the titles and icons of their pages, and the names, types and contents of the files you push + (together, “Push Content”) are encrypted on your device before they leave it, with a key that only your + password, or your recovery key, can unlock. They can be decrypted only on the devices you sign in to. We, and + Google, which runs the servers the App uses, hold Push Content only in that encrypted form and cannot read it, + apart from pushes made before end-to-end encryption that are still stored as they were sent (see Data Retention + below). Your recovery key is never sent to us or to Google, and your password only in the cases described below. + The <Link to="/security">Security</Link> page explains how this works and what it does not cover. </p> <h5>PERSONAL INFORMATION WE COLLECT</h5> diff --git a/apps/web/src/pages/Security.tsx b/apps/web/src/pages/Security.tsx index 5ae91dd..6f01f1e 100644 --- a/apps/web/src/pages/Security.tsx +++ b/apps/web/src/pages/Security.tsx @@ -53,7 +53,7 @@ export function Security() { <h5>WHAT IS ENCRYPTED</h5> <ul> - <li>The link you push, and the title of its page.</li> + <li>The link you push, and the title and icon of its page.</li> <li>A file’s name, its type and its contents. Files are stored under a random name.</li> <li> The words search looks for. Search uses scrambled tokens made with your key, so the server can match a search diff --git a/apps/web/src/services/e2ee.ts b/apps/web/src/services/e2ee.ts index 392be05..bf3d436 100644 --- a/apps/web/src/services/e2ee.ts +++ b/apps/web/src/services/e2ee.ts @@ -322,11 +322,16 @@ export interface Enc { ct: string; } -/** A URL push's plaintext (docs/e2ee.md, The push envelope). `title` is "" until known. */ +/** + * A URL push's plaintext (docs/e2ee.md, The push envelope). `title` is "" until known. + * `icon` is the page's icon as base64 PNG, added by the native device that scrapes the + * title (docs/e2ee.md, Icons); the web app reads it and never writes it. + */ export interface UrlEnvelope { url: string; title: string; ext: string; + icon?: string; } /** diff --git a/apps/web/src/services/pageIcon.ts b/apps/web/src/services/pageIcon.ts new file mode 100644 index 0000000..d7c1952 --- /dev/null +++ b/apps/web/src/services/pageIcon.ts @@ -0,0 +1,31 @@ +// A URL push's page icon as the list may draw it (docs/e2ee.md, Icons). Apart from +// `pushes.ts`, which needs Firebase, so that `npm run test:keys` checks it in plain Node. + +// 4096 bytes of PNG, at most 64 pixels a side (docs/e2ee.md, Icons). +const MAX_ICON_BYTES = 4096; +const MAX_ICON_SIDE = 64; +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * The envelope's icon, if it is one the list can show. It ends up in a `data:` URL, so it + * must be base64 and nothing else: a string that is not cannot smuggle anything into the + * `src`. It must also be what every client draws and nothing else, a PNG within 64 by 64, + * read from its header before the browser decodes it, so an icon some other writer got + * wrong is passed over here as it is on the phones. A PNG that passes and still + * will not decode is the `img`'s `onError`. + */ +export const iconOf = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length % 4 !== 0 || !BASE64.test(value)) return undefined; + // Counted in bytes, as the phones count it: 5464 characters without padding hold 4098. + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + if ((value.length / 4) * 3 - padding > MAX_ICON_BYTES) return undefined; + // The signature, then IHDR's length and type, then its width and height: 24 bytes, which + // are the first 32 characters of the base64. + const header = Uint8Array.from(atob(value.slice(0, 32)), (c) => c.charCodeAt(0)); + if (header.length < 24 || PNG_SIGNATURE.some((byte, i) => header[i] !== byte)) return undefined; + const view = new DataView(header.buffer); + const width = view.getUint32(16); + const height = view.getUint32(20); + return width >= 1 && width <= MAX_ICON_SIDE && height >= 1 && height <= MAX_ICON_SIDE ? value : undefined; +}; diff --git a/apps/web/src/services/pushes.ts b/apps/web/src/services/pushes.ts index 5882b87..0d20d46 100644 --- a/apps/web/src/services/pushes.ts +++ b/apps/web/src/services/pushes.ts @@ -46,6 +46,7 @@ import { searchWord, } from './e2ee'; import { deviceId, loadAccountKey, rememberKeyVersion } from './keyStore'; +import { iconOf } from './pageIcon'; /** * Pushes as docs/e2ee.md has them since phase 4 of that plan: written with their content in @@ -125,6 +126,8 @@ export interface Push { /** A URL push's URL, or a plaintext file push's download URL. */ url?: string; ext?: string; + /** A URL push's page icon, base64 PNG checked by `iconOf`; absent when it has none. */ + icon?: string; /** Set on a file push stored since encryption, which is opened through `downloadFile`. */ file?: PushFile; /** @@ -189,7 +192,13 @@ export async function readPush(snapshot: DocumentSnapshot<DocumentData>, keys: P }; } const url = stringOr(opened.url); - return { ...base, url, title: stringOr(opened.title) || url, ext: stringOr(opened.ext) || undefined }; + return { + ...base, + url, + title: stringOr(opened.title) || url, + ext: stringOr(opened.ext) || undefined, + icon: iconOf(opened.icon), + }; } /** diff --git a/apps/web/tests/pageIcon.mjs b/apps/web/tests/pageIcon.mjs new file mode 100644 index 0000000..9ebfbcc --- /dev/null +++ b/apps/web/tests/pageIcon.mjs @@ -0,0 +1,66 @@ +// Which page icons the list draws (src/services/pageIcon.ts): only what the envelope's +// `icon` may hold, a PNG of at most 64 by 64 in base64, read from its header, so that every +// client passes over the same wrong ones. Run by `npm run test:keys`, in plain Node. +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { iconOf } from '../src/services/pageIcon.ts'; + +/** The first 33 bytes of a PNG of `width` by `height`: its signature and its IHDR. */ +const pngHeader = (width, height) => { + const bytes = new Uint8Array(33); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return Buffer.from(bytes).toString('base64'); +}; + +describe('iconOf', () => { + test('takes a PNG within 64 by 64', () => { + for (const [w, h] of [ + [64, 64], + [1, 1], + [64, 32], + [16, 16], + ]) { + assert.equal(iconOf(pngHeader(w, h)), pngHeader(w, h)); + } + }); + + test('passes over a PNG larger than 64 a side, or with no pixels', () => { + for (const [w, h] of [ + [65, 64], + [64, 65], + [4096, 4096], + [0, 16], + ]) { + assert.equal(iconOf(pngHeader(w, h)), undefined); + } + }); + + test('passes over what is not a PNG', () => { + const gif = Buffer.from('GIF89a' + '\0'.repeat(27), 'binary').toString('base64'); + assert.equal(iconOf(gif), undefined); + assert.equal(iconOf(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64')), undefined); + }); + + test('passes over a string that is not base64, or is too long, before reading it', () => { + assert.equal(iconOf(undefined), undefined); + assert.equal(iconOf(''), undefined); + assert.equal(iconOf(`${pngHeader(16, 16)}"onerror="x`), undefined); + assert.equal(iconOf(pngHeader(16, 16).slice(0, -1)), undefined); + assert.equal(iconOf(pngHeader(16, 16) + 'A'.repeat(5464)), undefined); + }); + + test('takes 4096 bytes and not one more', () => { + const icon = (bytes) => { + const png = Buffer.alloc(bytes); + Buffer.from(pngHeader(16, 16), 'base64').copy(png); + return png.toString('base64'); + }; + assert.equal(iconOf(icon(4096)), icon(4096)); + assert.equal(iconOf(icon(4097)), undefined); + assert.equal(iconOf(icon(4098)), undefined); + }); +}); diff --git a/docs/e2ee.md b/docs/e2ee.md index f68dbc1..bd6396b 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -1,6 +1,6 @@ # End-to-end encryption -A push's content (its URL, its title, a file's name and bytes) is readable only on the account's own devices. Firestore, Cloud Storage, Cloud Functions, Google and we hold ciphertext and the metadata needed to count, deliver and expire it. The key is derived from the account password on the device, so the password is the encryption and the user can see that it is: there is no second passphrase and nothing to trust in the background. +A push's content (its URL, its page's title and icon, a file's name and bytes) is readable only on the account's own devices. Firestore, Cloud Storage, Cloud Functions, Google and we hold ciphertext and the metadata needed to count, deliver and expire it. The key is derived from the account password on the device, so the password is the encryption and the user can see that it is: there is no second passphrase and nothing to trust in the background. Decided 2026-10-26, after weighing a separate encryption passphrase (Pushbullet's model) and per-device key pairs with pairing. The password-derived design was chosen because every device already signs in with the password, so there is no new ceremony, and because a user who knows the password is the key understands what is encrypted and what a forgotten password costs. The costs that come with it are stated plainly below: a password reset without the recovery key loses the history, and a server-side title lookup for web pushes is no longer possible. @@ -103,7 +103,7 @@ The plaintext is a JSON object: | Type | Fields | | ------ | ------------------------------------------------------------------------------------------------------- | -| `url` | `url`, `title` (may be empty until scraped), `ext` | +| `url` | `url`, `title` (may be empty until scraped), `ext`, `icon` (optional, see Icons) | | `file` | `name`, `mime`, `size`, `ext`, `fk` (the per-file key, base64), `np` (the file's 8-byte nonce prefix, base64) | The plaintext is the object's JSON in UTF-8 and nothing more is fixed: there is no canonical form, since a client encrypts what its own serializer wrote and parses what it finds, so the vector gives the exact bytes it encrypted rather than an object to re-serialize. `size` is a JSON number. No `url`, `title`, `deviceName` or `ext` field may exist in plaintext on a document that has `enc`; the rules refuse them, so a downgraded client cannot leak by accident. @@ -187,6 +187,18 @@ The web app asks a Cloud Function, `getPageTitle`, to fetch a page's title, beca Until a title arrives the list shows the URL, as it does today for a push with no title. +## Icons + +A row shows the linked site's icon, and the icon travels in the envelope with the title, so no device asks anyone for it in order to draw the list. Until this change every client drew it from Google's favicon service (`www.google.com/s2/favicons?domain=<host>`), which handed Google, in plaintext and with the device's address, the host of every link each time a list was drawn: exactly what the envelope keeps from Google. Two other ways were weighed and rejected (decided 2026-11-12). Loading the icon from the site when the row is drawn tells the site about every device that views the list, and makes the web app contact sites it otherwise never touches. Drawing a letter on a colored tile leaks nothing but loses the logos people recognize a link by. + +`icon` is the standard base64 (with padding) of a PNG no larger than 64 by 64 pixels and no more than 4096 bytes. It is optional: a push made before this change, a push whose page has no icon a platform can decode, and a file push have none, and an empty string means none. + +**Capturing it.** The device that scrapes the title (Titles, above: Android, iOS and macOS) fetches the icon in the same pass, from the page it already downloaded. The candidates, in order: every `<link rel>` naming `apple-touch-icon` or `apple-touch-icon-precomposed`, then every one naming `icon` (including `shortcut icon`) whose `type` is not `image/svg+xml` and whose path does not end in `.svg`, then `/favicon.ico` at the page's origin. Each `href` is resolved against the page's final URL, after redirects (a `<base>` is not followed); only `http` and `https` are fetched. At most eight `<link>` candidates are tried before `/favicon.ico`, so a page full of broken links cannot hold the scraper for hundreds of requests, and a candidate over 256 KiB is skipped rather than decoded cut short. The first candidate that the platform's own image decoder opens decides: it is scaled down to fit 64 by 64 with its aspect kept (never up) and encoded as PNG; over 4096 bytes, it is scaled to fit 32 by 32 and tried once more, and over that there is no icon, without trying the next candidate. Only an encrypted URL push is given one; a plaintext push left from before encryption is not. The decoders differ (an ICO with several sizes may yield a different frame on each platform), which is why the vectors do not pin an icon: what is fixed is the field and its bounds, not the pixels. + +**Writing it.** The icon goes into the rewrite that fills in the title, so the rule (`enc` and `searchTokens`, `isEncryptedRetitle`) is unchanged. A rewrite happens when the title changed or an icon was found for a push that had none. A push that has an icon is not asked for one again and keeps it, and `icon` is written only when there is one, so a push without it encrypts the same bytes as before. 4096 bytes is 5464 characters of base64, which leaves the URL and title room inside `enc.ct`'s 16384 for every ordinary link; when the envelope with the icon would not fit, the icon is dropped and the title written alone. Which pushes are scraped is as before: a device's own, and a received one with no title. Old pushes are not scraped again for an icon. The rewrite is applied in a transaction to the push as it is at the write, not as it was read before the scrape: two devices scraping one push no longer write the same thing, since one may find an icon the other's decoder refuses, and a title-only write landing last would otherwise drop the icon for good, a titled push being scraped no more. + +**Showing it.** A URL push draws its decoded icon; without one, or when it does not decode, the icon for its `ext` (a link to a PDF has no page to take an icon from), and failing that the placeholder a locked push draws. A file push draws the icon for its `ext` or the placeholder. Nothing is fetched to draw a row: the file-type icons ship inside each app, never from pushthat.link, whose logs would otherwise see the type of every encrypted file a list showed. Every client draws only what the field may hold, a PNG of at most 64 by 64 and 4096 bytes, read from its header before it is decoded, so an icon some other writer got wrong is passed over the same way on every client rather than drawing on one platform and not another. The web app builds a `data:image/png;base64,` URL only from a string of base64 characters. + ## Rules `firestore.rules`: