on main help sign in
Forkgram 12.10.5 snapshot

Source: https://github.com/forkgram/TelegramAndroid/commit/221f4ca4c121c930e0c2d802385810c2c9c0d6e1 Forkgram-Commit: 221f4ca4c121c930e0c2d802385810c2c9c0d6e1 Forkgram-Version: 12.10.5 Telegram-Base: dc780e81ed1261c369c27870e8e0999a1eb0b600 (update to 12.10.5) Tree is Forkgram's dev branch at that commit with the .github folder removed.

commit1f24641ba9081ac7fb37fee2394c0cd3dd662672 authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> dateFri 2 Oct 2026, 05:49 (1 week ago) parent4543767
8 files changed+1110−2
TMessagesProj/src/main/java/org/telegram/messenger/forkgram/WebPublish.kt+675 TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java+23 TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java+18 TMessagesProj/src/main/java/org/telegram/ui/WebPublishSettingsActivity.java+194 TMessagesProj/src/main/res/drawable/msg_publish.xml+49 TMessagesProj/src/main/res/values/strings.xml+9 gradle.properties+2−2 tools/webpublish_test.py+140
TMessagesProj/src/main/java/org/telegram/messenger/forkgram/WebPublish.ktnew file+675
@@ -0,0 +1,675 @@
1+package org.telegram.messenger.forkgram
2+
3+import android.content.SharedPreferences
4+import android.widget.Toast
5+import org.json.JSONObject
6+import org.telegram.messenger.AndroidUtilities
7+import org.telegram.messenger.ApplicationLoader
8+import org.telegram.messenger.FileLoader
9+import org.telegram.messenger.FileLog
10+import org.telegram.messenger.ImageLocation
11+import org.telegram.messenger.MessageObject
12+import org.telegram.messenger.MessagesController
13+import org.telegram.messenger.NotificationCenter
14+import org.telegram.tgnet.TLRPC
15+import org.telegram.ui.ActionBar.BaseFragment
16+import org.telegram.ui.Components.BulletinFactory
17+import java.io.File
18+import java.io.FileInputStream
19+import java.io.FileOutputStream
20+import java.io.InputStream
21+import java.lang.ref.WeakReference
22+import java.net.HttpURLConnection
23+import java.net.URL
24+import java.util.UUID
25+import java.util.concurrent.atomic.AtomicReference
26+import kotlin.math.min
27+
28+/**
29+ * "Publish to web service": build text + media from messages, upload
30+ * everything to the web service in one request, put the page link into the
31+ * clipboard using a user-defined template (%q placeholder).
32+ *
33+ * No local files are written except a temporary cache used for the upload,
34+ * which is deleted afterwards (on error too).
35+ *
36+ * Service contract:
37+ * - POST {base}/upload, multipart: field "text" (final text),
38+ * one "files[]" field per media file (filename + content type)
39+ * - auth header X-Upload-Token on every request (never logged, never shown)
40+ * - User-Agent: Forkgram/1.0 on every request
41+ * - response JSON {"id": "<id>", "url": "<page url>"}
42+ * - DELETE <url> with X-Upload-Token removes the page
43+ */
44+object WebPublishConfig {
45+
46+ const val PREF_BASE = "webpublish_base"
47+ const val PREF_TOKEN = "webpublish_token"
48+ const val PREF_TEMPLATE = "webpublish_template"
49+
50+ const val DEFAULT_TEMPLATE = "%q"
51+
52+ const val FIELD_BASE = "base"
53+ const val FIELD_TOKEN = "token"
54+ const val FIELD_TEMPLATE = "template"
55+
56+ @JvmStatic
57+ fun prefs(): SharedPreferences {
58+ return MessagesController.getGlobalMainSettings()
59+ }
60+
61+ @JvmStatic
62+ fun base(): String = prefs().getString(PREF_BASE, "") ?: ""
63+
64+ /**
65+ * Upload token: user-managed credential, sent as X-Upload-Token.
66+ * Never written to logs, never shown in UI (masked field only).
67+ * Deliberately NOT overridable via BuildConfig so it never lands in APKs.
68+ */
69+ @JvmStatic
70+ fun token(): String = prefs().getString(PREF_TOKEN, "") ?: ""
71+
72+ @JvmStatic
73+ fun hasToken(): Boolean = token().isNotEmpty()
74+
75+ @JvmStatic
76+ fun template(): String {
77+ return prefs().getString(PREF_TEMPLATE, DEFAULT_TEMPLATE) ?: DEFAULT_TEMPLATE
78+ }
79+
80+ /**
81+ * Validate the effective configuration.
82+ * @return map of field name -> human-readable error; empty means valid.
83+ */
84+ @JvmStatic
85+ fun validate(): Map<String, String> {
86+ return validateValues(base().trim(), token(), template())
87+ }
88+
89+ @JvmStatic
90+ fun validateValues(base: String, token: String, template: String): Map<String, String> {
91+ val errors = LinkedHashMap<String, String>()
92+ val b = base.trim()
93+ if (b.isEmpty()) {
94+ errors[FIELD_BASE] = "Service base URL is required"
95+ } else {
96+ try {
97+ val url = URL(b)
98+ if (url.protocol != "http" && url.protocol != "https") {
99+ errors[FIELD_BASE] = "Base URL must start with http:// or https://"
100+ } else if (url.host.isNullOrEmpty()) {
101+ errors[FIELD_BASE] = "Base URL has no host"
102+ }
103+ } catch (e: Exception) {
104+ errors[FIELD_BASE] = "Base URL is not a valid URL"
105+ }
106+ }
107+ if (token.isEmpty()) {
108+ errors[FIELD_TOKEN] = "Upload token is required"
109+ }
110+ if (template.isEmpty()) {
111+ errors[FIELD_TEMPLATE] = "Clipboard template is required (use %q for the link)"
112+ }
113+ return errors
114+ }
115+
116+ @JvmStatic
117+ fun isConfigured(): Boolean {
118+ return base().trim().isNotEmpty()
119+ }
120+
121+ @JvmStatic
122+ fun uploadUrl(): String = base().trim().trimEnd('/') + "/upload"
123+
124+ @JvmStatic
125+ fun applyClipboardTemplate(link: String): String {
126+ val t = template()
127+ return if (t.contains("%q")) t.replace("%q", link) else "$t $link"
128+ }
129+}
130+
131+object WebPublishClient {
132+
133+ const val USER_AGENT = "Forkgram/1.0"
134+
135+ const val IMAGE_MAX_BYTES = 5L * 1024 * 1024
136+ const val VIDEO_MAX_BYTES = 20L * 1024 * 1024
137+ const val TEXT_MAX_BYTES = 5L * 1024 * 1024
138+
139+ const val CONNECT_TIMEOUT_MS = 15_000
140+ const val READ_TIMEOUT_MS = 60_000
141+
142+ data class UploadFile(val file: File, val fileName: String, val mimeType: String)
143+
144+ data class Uploaded(val id: String, val url: String)
145+
146+ sealed class Result {
147+ data class Ok(val uploaded: Uploaded) : Result()
148+ data class Fail(val message: String) : Result()
149+ }
150+
151+ sealed class DeleteResult {
152+ object Ok : DeleteResult()
153+ data class Fail(val message: String) : DeleteResult()
154+ }
155+
156+ private fun readStream(conn: HttpURLConnection, code: Int): String {
157+ val stream: InputStream? = try {
158+ if (code in 200..299) conn.inputStream else conn.errorStream
159+ } catch (e: Exception) {
160+ null
161+ }
162+ if (stream == null) return ""
163+ return try {
164+ stream.bufferedReader(Charsets.UTF_8).use { it.readText() }
165+ } catch (e: Exception) {
166+ ""
167+ }
168+ }
169+
170+ private fun httpErrorMessage(code: Int, retryAfter: String?): String {
171+ return when (code) {
172+ 413 -> "Server rejected the upload: file too large (413)"
173+ 415 -> "Server rejected the upload: file type not allowed (415)"
174+ 429 -> {
175+ val suffix = if (!retryAfter.isNullOrEmpty()) " (retry after $retryAfter)" else ""
176+ "Server rate limit exceeded$suffix (429)"
177+ }
178+ 401, 403 -> "Server rejected the upload: bad upload token (HTTP $code)"
179+ 404 -> "Server rejected the upload: unknown endpoint (HTTP 404)"
180+ else -> "Server error: HTTP $code"
181+ }
182+ }
183+
184+ private fun encodeField(out: java.io.OutputStream, boundary: String, name: String, value: String) {
185+ val head = "--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n"
186+ out.write(head.toByteArray(Charsets.UTF_8))
187+ out.write(value.toByteArray(Charsets.UTF_8))
188+ out.write("\r\n".toByteArray(Charsets.UTF_8))
189+ }
190+
191+ /**
192+ * Upload text + files in one request.
193+ * Response link and id are taken from the "url" and "id" fields.
194+ */
195+ @JvmStatic
196+ fun uploadReport(uploadUrl: String, token: String, text: String, files: List<UploadFile>): Result {
197+ val boundary = "----Forkgram${UUID.randomUUID()}"
198+ var conn: HttpURLConnection? = null
199+ try {
200+ conn = URL(uploadUrl).openConnection() as HttpURLConnection
201+ conn.connectTimeout = CONNECT_TIMEOUT_MS
202+ conn.readTimeout = READ_TIMEOUT_MS
203+ conn.requestMethod = "POST"
204+ conn.doOutput = true
205+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
206+ conn.setRequestProperty("User-Agent", USER_AGENT)
207+ conn.setRequestProperty("X-Upload-Token", token)
208+ conn.outputStream.use { out ->
209+ encodeField(out, boundary, "text", text)
210+ for (f in files) {
211+ val head = "--$boundary\r\n" +
212+ "Content-Disposition: form-data; name=\"files[]\"; filename=\"${f.fileName}\"\r\n" +
213+ "Content-Type: ${f.mimeType}\r\n\r\n"
214+ out.write(head.toByteArray(Charsets.UTF_8))
215+ FileInputStream(f.file).use { input ->
216+ val buf = ByteArray(64 * 1024)
217+ while (true) {
218+ val n = input.read(buf)
219+ if (n < 0) break
220+ out.write(buf, 0, n)
221+ }
222+ }
223+ out.write("\r\n".toByteArray(Charsets.UTF_8))
224+ }
225+ out.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
226+ out.flush()
227+ }
228+ val code = conn.responseCode
229+ val retryAfter = try {
230+ conn.getHeaderField("Retry-After")
231+ } catch (e: Exception) {
232+ null
233+ }
234+ val body = readStream(conn, code)
235+ if (code in 200..299) {
236+ try {
237+ val obj = JSONObject(body.trim())
238+ val url = obj.optString("url", "")
239+ val id = obj.optString("id", "")
240+ if (url.isEmpty() || id.isEmpty()) {
241+ return Result.Fail("Server returned no link")
242+ }
243+ return Result.Ok(Uploaded(id, url))
244+ } catch (e: Exception) {
245+ return Result.Fail("Server returned an unreadable response")
246+ }
247+ }
248+ return Result.Fail(httpErrorMessage(code, retryAfter))
249+ } catch (e: java.net.SocketTimeoutException) {
250+ return Result.Fail("Connection timed out")
251+ } catch (e: java.net.UnknownHostException) {
252+ return Result.Fail("Cannot reach the server (unknown host)")
253+ } catch (e: javax.net.ssl.SSLException) {
254+ return Result.Fail("Secure connection failed")
255+ } catch (e: Exception) {
256+ FileLog.e(e)
257+ return Result.Fail("Network error: ${e.message ?: e.javaClass.simpleName}")
258+ } finally {
259+ try {
260+ conn?.disconnect()
261+ } catch (e: Exception) {
262+ // ignore
263+ }
264+ }
265+ }
266+
267+ /** Delete a previously uploaded page by its URL. Requires the upload token. */
268+ @JvmStatic
269+ fun deleteReport(pageUrl: String, token: String): DeleteResult {
270+ var conn: HttpURLConnection? = null
271+ try {
272+ conn = URL(pageUrl).openConnection() as HttpURLConnection
273+ conn.connectTimeout = CONNECT_TIMEOUT_MS
274+ conn.readTimeout = READ_TIMEOUT_MS
275+ conn.requestMethod = "DELETE"
276+ conn.setRequestProperty("User-Agent", USER_AGENT)
277+ conn.setRequestProperty("X-Upload-Token", token)
278+ val code = conn.responseCode
279+ readStream(conn, code)
280+ if (code in 200..299) {
281+ return DeleteResult.Ok
282+ }
283+ return DeleteResult.Fail(httpErrorMessage(code, null))
284+ } catch (e: Exception) {
285+ FileLog.e(e)
286+ return DeleteResult.Fail("Network error: ${e.message ?: e.javaClass.simpleName}")
287+ } finally {
288+ try {
289+ conn?.disconnect()
290+ } catch (e: Exception) {
291+ // ignore
292+ }
293+ }
294+ }
295+}
296+
297+object WebPublishMarkdown {
298+
299+ @JvmStatic
300+ fun textOf(msg: MessageObject): String {
301+ val caption = msg.caption
302+ if (!caption.isNullOrEmpty()) return caption.toString()
303+ val text = msg.messageText
304+ if (text != null && text.isNotEmpty()) return text.toString()
305+ return ""
306+ }
307+}
308+
309+/**
310+ * Orchestrates one publish run on a dedicated worker thread.
311+ *
312+ * The [PublishSession] instance is owned by the worker thread and is passed
313+ * explicitly through every step (strong references only, no weak links), so
314+ * the chain cannot die silently mid-flight. The terminal step reports to the
315+ * UI and drops the session, so nothing is retained afterwards.
316+ */
317+object WebPublishFlow {
318+
319+ private const val DOWNLOAD_TIMEOUT_SEC = 120L
320+ private const val DOWNLOAD_POLL_MS = 400L
321+
322+ @JvmStatic
323+ @JvmOverloads
324+ fun publish(
325+ currentAccount: Int,
326+ messages: List<MessageObject>,
327+ fragment: BaseFragment?,
328+ onSuccess: Runnable? = null
329+ ) {
330+ val errors = WebPublishConfig.validate()
331+ if (errors.isNotEmpty()) {
332+ notifyError(fragment, errors.values.first())
333+ return
334+ }
335+ if (messages.isEmpty()) {
336+ notifyError(fragment, "No messages selected")
337+ return
338+ }
339+ // Owning copies for the worker thread: nothing is captured by reference
340+ // from the caller's stack.
341+ val snapshot = ArrayList(messages)
342+ val fragmentRef = WeakReference<BaseFragment>(fragment)
343+ AndroidUtilities.runOnUIThread {
344+ Toast.makeText(ApplicationLoader.applicationContext, "Uploading…", Toast.LENGTH_SHORT).show()
345+ }
346+ val session = PublishSession(currentAccount, snapshot, fragmentRef, onSuccess)
347+ Thread({
348+ session.run()
349+ // Terminal step: session is not passed anywhere else, so it is freed here.
350+ }, "webpublish").start()
351+ }
352+
353+ @JvmStatic
354+ fun notifySuccess(fragment: BaseFragment?, clipboardText: String) {
355+ AndroidUtilities.runOnUIThread {
356+ AndroidUtilities.addToClipboard(clipboardText)
357+ if (BulletinFactory.canShowBulletin(fragment)) {
358+ BulletinFactory.of(fragment).createCopyBulletin("Link copied to clipboard").show()
359+ } else {
360+ Toast.makeText(ApplicationLoader.applicationContext, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
361+ }
362+ }
363+ }
364+
365+ @JvmStatic
366+ fun notifyError(fragment: BaseFragment?, message: String) {
367+ AndroidUtilities.runOnUIThread {
368+ if (BulletinFactory.canShowBulletin(fragment)) {
369+ BulletinFactory.of(fragment).createErrorBulletin(message).show()
370+ } else {
371+ Toast.makeText(ApplicationLoader.applicationContext, message, Toast.LENGTH_LONG).show()
372+ }
373+ }
374+ }
375+
376+ private data class PendingMedia(
377+ val fileName: String,
378+ val mimeType: String,
379+ val source: File,
380+ val isImage: Boolean,
381+ val isVideo: Boolean
382+ )
383+
384+ /**
385+ * All state of one publish run. Lives on the worker thread; every step is
386+ * a method on this instance, so the chain holds itself alive until done.
387+ */
388+ private class PublishSession(
389+ private val currentAccount: Int,
390+ private val messages: List<MessageObject>,
391+ private val fragmentRef: WeakReference<BaseFragment>,
392+ // Held strongly only for the duration of the run and dropped in the
393+ // terminal step together with the session itself.
394+ private val onSuccess: Runnable?
395+ ) {
396+ private val tempDir = File(
397+ File(ApplicationLoader.applicationContext.cacheDir, "webpublish"),
398+ "run_${System.currentTimeMillis()}"
399+ )
400+ private var tempCounter = 0
401+ private var aborted = false
402+
403+ fun run() {
404+ try {
405+ runInternal()
406+ } catch (e: Exception) {
407+ FileLog.e(e)
408+ fail("Publish failed: ${e.message ?: e.javaClass.simpleName}")
409+ } finally {
410+ deleteTemp()
411+ }
412+ }
413+
414+ private fun fragment(): BaseFragment? = fragmentRef.get()
415+
416+ private fun fail(message: String) {
417+ aborted = true
418+ notifyError(fragment(), message)
419+ }
420+
421+ private fun runInternal() {
422+ val base = WebPublishConfig.base().trim().trimEnd('/')
423+ val token = WebPublishConfig.token()
424+ // Step 1: assemble text, resolve local media files.
425+ val textBuilder = StringBuilder()
426+ val pending = ArrayList<PendingMedia>()
427+ for (msg in messages) {
428+ val text = WebPublishMarkdown.textOf(msg)
429+ if (text.isNotEmpty()) {
430+ if (textBuilder.isNotEmpty()) textBuilder.append("\n\n")
431+ textBuilder.append(text)
432+ }
433+ val media = resolveMedia(msg)
434+ if (aborted) return
435+ if (media != null) {
436+ val size = media.source.length()
437+ if (media.isImage && size > WebPublishClient.IMAGE_MAX_BYTES) {
438+ fail("Image is larger than 5 MB")
439+ return
440+ }
441+ if (media.isVideo && size > WebPublishClient.VIDEO_MAX_BYTES) {
442+ fail("Video is larger than 20 MB")
443+ return
444+ }
445+ pending.add(media)
446+ }
447+ }
448+ val text = textBuilder.toString()
449+ if (text.isEmpty() && pending.isEmpty()) {
450+ fail("Nothing to publish")
451+ return
452+ }
453+ if (text.toByteArray(Charsets.UTF_8).size > WebPublishClient.TEXT_MAX_BYTES) {
454+ fail("Resulting text is larger than 5 MB")
455+ return
456+ }
457+ // Step 2: upload text + files sequentially in one request.
458+ val files = pending.map { WebPublishClient.UploadFile(it.source, it.fileName, it.mimeType) }
459+ when (val r = WebPublishClient.uploadReport("$base/upload", token, text, files)) {
460+ is WebPublishClient.Result.Ok -> {
461+ val clipboardText = WebPublishConfig.applyClipboardTemplate(r.uploaded.url)
462+ notifySuccess(fragment(), clipboardText)
463+ val cb = onSuccess
464+ if (cb != null) {
465+ AndroidUtilities.runOnUIThread {
466+ try {
467+ cb.run()
468+ } catch (e: Exception) {
469+ FileLog.e(e)
470+ }
471+ }
472+ }
473+ }
474+ is WebPublishClient.Result.Fail -> fail(r.message)
475+ }
476+ }
477+
478+ private fun resolveMedia(msg: MessageObject): PendingMedia? {
479+ return try {
480+ resolveMediaInternal(msg)
481+ } catch (e: Exception) {
482+ FileLog.e(e)
483+ null
484+ }
485+ }
486+
487+ private fun resolveMediaInternal(msg: MessageObject): PendingMedia? {
488+ val loader = FileLoader.getInstance(currentAccount)
489+ if (msg.isPhoto) {
490+ val media = msg.messageOwner?.media ?: return null
491+ val photo = media.photo ?: return null
492+ val thumbs = msg.photoThumbs
493+ if (thumbs.isNullOrEmpty()) return null
494+ val biggest = FileLoader.getClosestPhotoSizeWithSize(thumbs, 2560)
495+ ?: thumbs.maxByOrNull { it.size } ?: return null
496+ var file = existingFile(msg.messageOwner?.attachPath)
497+ ?: loader.getPathToMessage(msg.messageOwner)?.takeIf { it.exists() }
498+ if (file == null) {
499+ val location = ImageLocation.getForPhoto(biggest, photo) ?: return null
500+ AndroidUtilities.runOnUIThread {
501+ try {
502+ loader.loadFile(location, msg, "jpg", FileLoader.PRIORITY_HIGH, 1)
503+ } catch (e: Exception) {
504+ FileLog.e(e)
505+ }
506+ }
507+ file = waitForFile {
508+ existingFile(msg.messageOwner?.attachPath)
509+ ?: loader.getPathToMessage(msg.messageOwner)?.takeIf { it.exists() }
510+ }
511+ if (file == null) {
512+ fail("Could not download photo")
513+ return null
514+ }
515+ }
516+ val staged = stageToTemp(file, "photo", ".jpg") ?: return null
517+ return PendingMedia("photo.jpg", "image/jpeg", staged, isImage = true, isVideo = false)
518+ }
519+ val document = try {
520+ msg.getDocument()
521+ } catch (e: Exception) {
522+ null
523+ }
524+ if (document != null) {
525+ val isVideo = try {
526+ msg.isVideo || MessageObject.isVideoDocument(document)
527+ } catch (e: Exception) {
528+ false
529+ }
530+ val docAny: TLRPC.Document = document
531+ var file = existingFile(msg.messageOwner?.attachPath)
532+ ?: loader.getPathToAttach(docAny, null, false, true)?.takeIf { it.exists() }
533+ ?: loader.getPathToMessage(msg.messageOwner)?.takeIf { it.exists() }
534+ if (file == null) {
535+ AndroidUtilities.runOnUIThread {
536+ try {
537+ loader.loadFile(docAny, msg, FileLoader.PRIORITY_HIGH, 0)
538+ } catch (e: Exception) {
539+ FileLog.e(e)
540+ }
541+ }
542+ file = waitForFile {
543+ loader.getPathToAttach(docAny, null, false, true)?.takeIf { it.exists() }
544+ ?: loader.getPathToMessage(msg.messageOwner)?.takeIf { it.exists() }
545+ }
546+ if (file == null) {
547+ fail("Could not download file")
548+ return null
549+ }
550+ }
551+ val originalName = try {
552+ FileLoader.getDocumentFileName(docAny).ifEmpty { "file" }
553+ } catch (e: Exception) {
554+ "file"
555+ }
556+ val mime = try {
557+ val m = msg.getMimeType()
558+ if (m.isNullOrEmpty()) guessMime(originalName) else m
559+ } catch (e: Exception) {
560+ guessMime(originalName)
561+ }
562+ val staged = stageToTemp(file, "file", extensionOf(originalName)) ?: return null
563+ return PendingMedia(sanitizeFileName(originalName), mime, staged, isImage = false, isVideo = isVideo)
564+ }
565+ return null
566+ }
567+
568+ private fun existingFile(path: String?): File? {
569+ if (path.isNullOrEmpty()) return null
570+ val f = File(path)
571+ return if (f.exists() && f.isFile) f else null
572+ }
573+
574+ private fun waitForFile(find: () -> File?): File? {
575+ // Fast path.
576+ find()?.let { return it }
577+ val failed = AtomicReference<String?>(null)
578+ val observer = object : NotificationCenter.NotificationCenterDelegate {
579+ override fun didReceivedNotification(id: Int, account: Int, vararg args: Any) {
580+ if (account != currentAccount) return
581+ if (id == NotificationCenter.fileLoaded) {
582+ find()?.let { /* exists now; poll below will pick it up */ }
583+ } else if (id == NotificationCenter.fileLoadFailed) {
584+ failed.compareAndSet(null, "download")
585+ }
586+ }
587+ }
588+ val nc = NotificationCenter.getInstance(currentAccount)
589+ nc.addObserver(observer, NotificationCenter.fileLoaded)
590+ nc.addObserver(observer, NotificationCenter.fileLoadFailed)
591+ try {
592+ val deadline = System.currentTimeMillis() + DOWNLOAD_TIMEOUT_SEC * 1000
593+ while (System.currentTimeMillis() < deadline) {
594+ find()?.let { return it }
595+ if (failed.get() != null) {
596+ // Give it one last chance: the file may have arrived anyway.
597+ find()?.let { return it }
598+ return null
599+ }
600+ try {
601+ Thread.sleep(DOWNLOAD_POLL_MS)
602+ } catch (e: InterruptedException) {
603+ return find()
604+ }
605+ }
606+ return find()
607+ } finally {
608+ nc.removeObserver(observer, NotificationCenter.fileLoaded)
609+ nc.removeObserver(observer, NotificationCenter.fileLoadFailed)
610+ }
611+ }
612+
613+ private fun stageToTemp(source: File, prefix: String, ext: String): File? {
614+ return try {
615+ if (!tempDir.exists()) tempDir.mkdirs()
616+ val dest = File(tempDir, "${prefix}_${tempCounter++}${if (ext.startsWith(".")) ext else ".$ext"}")
617+ FileInputStream(source).use { input ->
618+ FileOutputStream(dest).use { output ->
619+ val buf = ByteArray(64 * 1024)
620+ while (true) {
621+ val n = input.read(buf)
622+ if (n < 0) break
623+ output.write(buf, 0, n)
624+ }
625+ }
626+ }
627+ dest
628+ } catch (e: Exception) {
629+ FileLog.e(e)
630+ fail("Could not prepare file for upload")
631+ null
632+ }
633+ }
634+
635+ private fun deleteTemp() {
636+ try {
637+ if (tempDir.exists()) tempDir.deleteRecursively()
638+ } catch (e: Exception) {
639+ // ignore
640+ }
641+ }
642+
643+ private fun guessMime(name: String): String {
644+ val lower = name.lowercase()
645+ return when {
646+ lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg"
647+ lower.endsWith(".png") -> "image/png"
648+ lower.endsWith(".gif") -> "image/gif"
649+ lower.endsWith(".webp") -> "image/webp"
650+ lower.endsWith(".mp4") -> "video/mp4"
651+ lower.endsWith(".mov") -> "video/quicktime"
652+ lower.endsWith(".webm") -> "video/webm"
653+ lower.endsWith(".mkv") -> "video/x-matroska"
654+ lower.endsWith(".mp3") -> "audio/mpeg"
655+ lower.endsWith(".ogg") || lower.endsWith(".oga") -> "audio/ogg"
656+ lower.endsWith(".pdf") -> "application/pdf"
657+ lower.endsWith(".zip") -> "application/zip"
658+ else -> "application/octet-stream"
659+ }
660+ }
661+
662+ private fun extensionOf(name: String): String {
663+ val dot = name.lastIndexOf('.')
664+ if (dot < 0 || dot == name.length - 1) return ".bin"
665+ val ext = name.substring(dot + 1)
666+ if (ext.length > 10 || !ext.all { it.isLetterOrDigit() }) return ".bin"
667+ return ".$ext"
668+ }
669+
670+ private fun sanitizeFileName(name: String): String {
671+ val clean = name.replace(Regex("[\\r\\n\"]"), "_").trim()
672+ return if (clean.isEmpty()) "file" else clean.substring(0, min(clean.length, 128))
673+ }
674+ }
675+}
TMessagesProj/src/main/java/org/telegram/ui/ChatActivity.java+23
@@ -191,6 +191,8 @@ import org.telegram.messenger.forkgram.ForkUtils;
191191import org.telegram.messenger.forkgram.ExtractMediaFromPreview;
192192import org.telegram.messenger.forkgram.FormattingMenu;
193193import org.telegram.messenger.forkgram.MediaSpoiler;
194+import org.telegram.messenger.forkgram.WebPublishConfig;
195+import org.telegram.messenger.forkgram.WebPublishFlow;
194196import org.telegram.messenger.support.LongSparseIntArray;
195197import org.telegram.messenger.utils.FBool;
196198import org.telegram.messenger.utils.OnPostDrawView;
@@ -1794,6 +1796,7 @@ public class ChatActivity extends BaseFragment implements
17941796 private final static int forward = 11;
17951797 private final static int forward_anonym = 111;
17961798 private final static int select_between = 112;
1799+ private final static int publish_web = 113;
17971800 private final static int delete = 12;
17981801 private final static int chat_enc_timer = 13;
17991802 private final static int chat_menu_attach = 14;
@@ -3961,6 +3964,8 @@ public class ChatActivity extends BaseFragment implements
39613964 showAnonymShareAlert(false);
39623965 } else if (id == share) {
39633966 share();
3967+ } else if (id == publish_web) {
3968+ publishSelectedToWeb();
39643969 } else if (id == open_direct) {
39653970 if (currentChat == null) return;
39663971 presentFragment(ChatActivity.of(-currentChat.linked_monoforum_id));
@@ -10658,6 +10663,9 @@ public class ChatActivity extends BaseFragment implements
1065810663 actionModeViews.add(actionMode.addItemWithWidth(forward_anonym, R.drawable.ic_ab_forward_anonym, dp(48), LocaleController.getString(R.string.Forward)));
1065910664 }
1066010665 actionModeViews.add(actionMode.addItemWithWidth(share, R.drawable.msg_shareout, dp(48), LocaleController.getString(R.string.ShareFile)));
10666+ if (WebPublishConfig.isConfigured()) {
10667+ actionModeViews.add(actionMode.addItemWithWidth(publish_web, R.drawable.msg_publish, dp(48), LocaleController.getString(R.string.WebPublish)));
10668+ }
1066110669 actionModeViews.add(actionMode.addItemWithWidth(delete, R.drawable.msg_delete, dp(48), LocaleController.getString(R.string.Delete)));
1066210670 } else {
1066310671 actionModeViews.add(actionMode.addItemWithWidth(edit, R.drawable.msg_edit, dp(48), LocaleController.getString(R.string.Edit)));
@@ -12679,6 +12687,21 @@ public class ChatActivity extends BaseFragment implements
1267912687 updateSelectedMessageReactions();
1268012688 }
1268112689
12690+ private void publishSelectedToWeb() {
12691+ ArrayList<MessageObject> messageObjects = new ArrayList<>();
12692+ for (int a = 1; a >= 0; a--) {
12693+ for (int b = 0; b < selectedMessagesIds[a].size(); b++) {
12694+ MessageObject messageObject = selectedMessagesIds[a].valueAt(b);
12695+ if (messageObject != null) {
12696+ messageObjects.add(messageObject);
12697+ }
12698+ }
12699+ }
12700+ Collections.sort(messageObjects, (m1, m2) -> Integer.compare(m1.getId(), m2.getId()));
12701+ // Selection stays visible during the upload; cleared only on success.
12702+ WebPublishFlow.publish(currentAccount, messageObjects, this, this::clearSelectionMode);
12703+ }
12704+
1268212705 private void openForward(boolean fromActionBar) {
1268312706 if (isPeerNoForwards() || hasSelectedNoforwardsMessage()) {
1268412707 // We should update text if user changed locale without re-opening chat activity
TMessagesProj/src/main/java/org/telegram/ui/ForkSettingsActivity.java+18
@@ -46,6 +46,7 @@ import org.telegram.messenger.forkgram.ForkSettingsLock;
4646import org.telegram.messenger.forkgram.HiddenAccountHelper;
4747import org.telegram.messenger.forkgram.LinkReplacements;
4848import org.telegram.messenger.forkgram.SettingsBackup;
49+import org.telegram.messenger.forkgram.WebPublishConfig;
4950import org.telegram.ui.ActionBar.ActionBar;
5051import org.telegram.ui.ActionBar.ActionBarMenu;
5152import org.telegram.ui.ActionBar.ActionBarMenuItem;
@@ -152,6 +153,8 @@ public class ForkSettingsActivity extends BaseFragment {
152153
153154 public static final int ID_LASTFM_LOGIN = 90;
154155
156+ public static final int ID_WEB_PUBLISH = 101;
157+
155158 public static final int ID_EXPORT_SETTINGS = 95;
156159 public static final int ID_IMPORT_SETTINGS = 96;
157160
@@ -331,6 +334,14 @@ public class ForkSettingsActivity extends BaseFragment {
331334 return hiddenCount > 0 ? Integer.toString(hiddenCount) : LocaleController.getString(R.string.PasswordOff);
332335 }
333336
337+ private String getWebPublishText() {
338+ String base = WebPublishConfig.base().trim();
339+ if (base.isEmpty()) {
340+ return LocaleController.getString(R.string.PasswordOff);
341+ }
342+ return base;
343+ }
344+
334345 @Override
335346 public View createView(Context context) {
336347 actionBar.setBackButtonImage(R.drawable.ic_ab_back);
@@ -717,6 +728,10 @@ public class ForkSettingsActivity extends BaseFragment {
717728 items.add(UItem.asShadow(null));
718729 }
719730
731+ items.add(UItem.asHeader(LocaleController.getString(R.string.WebPublishSection)));
732+ items.add(UItem.asSettingsCell(ID_WEB_PUBLISH, LocaleController.getString(R.string.WebPublish), getWebPublishText()));
733+ items.add(UItem.asShadow(LocaleController.getString(R.string.WebPublishInfo)));
734+
720735 items.add(UItem.asHeader(LocaleController.getString(R.string.ForkSectionBackup)));
721736 items.add(UItem.asSettingsCell(ID_EXPORT_SETTINGS, LocaleController.getString(R.string.ExportSettings), ""));
722737 items.add(UItem.asSettingsCell(ID_IMPORT_SETTINGS, LocaleController.getString(R.string.ImportSettings), ""));
@@ -915,6 +930,9 @@ public class ForkSettingsActivity extends BaseFragment {
915930 } else if (id == ID_LASTFM_LOGIN) {
916931 presentFragment(new LastFmLoginActivity());
917932
933+ } else if (id == ID_WEB_PUBLISH) {
934+ presentFragment(new WebPublishSettingsActivity());
935+
918936 } else if (id == ID_EXPORT_SETTINGS) {
919937 exportSettings();
920938 } else if (id == ID_IMPORT_SETTINGS) {
TMessagesProj/src/main/java/org/telegram/ui/WebPublishSettingsActivity.javanew file+194
@@ -0,0 +1,194 @@
1+package org.telegram.ui;
2+
3+import android.content.Context;
4+import android.content.SharedPreferences;
5+import android.text.InputType;
6+import android.text.method.PasswordTransformationMethod;
7+import android.view.Gravity;
8+import android.view.View;
9+import android.view.inputmethod.EditorInfo;
10+import android.widget.FrameLayout;
11+import android.widget.LinearLayout;
12+import android.widget.ScrollView;
13+import android.widget.TextView;
14+
15+import org.telegram.messenger.AndroidUtilities;
16+import org.telegram.messenger.LocaleController;
17+import org.telegram.messenger.MessagesController;
18+import org.telegram.messenger.R;
19+import org.telegram.messenger.forkgram.WebPublishConfig;
20+import org.telegram.ui.ActionBar.ActionBar;
21+import org.telegram.ui.ActionBar.BaseFragment;
22+import org.telegram.ui.ActionBar.Theme;
23+import org.telegram.ui.Components.EditTextBoldCursor;
24+import org.telegram.ui.Components.LayoutHelper;
25+import org.telegram.ui.Components.OutlineEditText;
26+
27+import java.util.Map;
28+
29+public class WebPublishSettingsActivity extends BaseFragment {
30+
31+ private static final int DONE_BUTTON = 1;
32+
33+ private EditTextBoldCursor baseField;
34+ private EditTextBoldCursor tokenField;
35+ private EditTextBoldCursor templateField;
36+
37+ private TextView baseError;
38+ private TextView tokenError;
39+ private TextView templateError;
40+
41+ @Override
42+ public View createView(Context context) {
43+ actionBar.setBackButtonImage(R.drawable.ic_ab_back);
44+ actionBar.setTitle(LocaleController.getString(R.string.WebPublish));
45+ actionBar.setAllowOverlayTitle(true);
46+ actionBar.setBackgroundColor(Theme.getColor(Theme.key_windowBackgroundWhite));
47+ actionBar.setTitleColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText));
48+ actionBar.setItemsColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText), false);
49+ if (AndroidUtilities.isTablet()) {
50+ actionBar.setOccupyStatusBar(false);
51+ }
52+ actionBar.setActionBarMenuOnItemClick(new ActionBar.ActionBarMenuOnItemClick() {
53+ @Override
54+ public void onItemClick(int id) {
55+ if (id == -1) {
56+ finishFragment();
57+ } else if (id == DONE_BUTTON) {
58+ save();
59+ }
60+ }
61+ });
62+ actionBar.createMenu().addItemWithWidth(DONE_BUTTON, R.drawable.ic_ab_done, AndroidUtilities.dp(56), LocaleController.getString(R.string.Done));
63+
64+ fragmentView = new FrameLayout(context);
65+ fragmentView.setBackgroundColor(Theme.getColor(Theme.key_windowBackgroundWhite));
66+ FrameLayout frameLayout = (FrameLayout) fragmentView;
67+
68+ ScrollView scrollView = new ScrollView(context);
69+ scrollView.setFillViewport(true);
70+ frameLayout.addView(scrollView, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
71+
72+ LinearLayout content = new LinearLayout(context);
73+ content.setOrientation(LinearLayout.VERTICAL);
74+ int pad = AndroidUtilities.dp(16);
75+ content.setPadding(pad, pad, pad, pad);
76+ scrollView.addView(content, new ScrollView.LayoutParams(ScrollView.LayoutParams.MATCH_PARENT, ScrollView.LayoutParams.WRAP_CONTENT));
77+
78+ TextView info = new TextView(context);
79+ info.setText(LocaleController.getString(R.string.WebPublishInfo));
80+ info.setTextSize(14);
81+ info.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteGrayText2));
82+ content.addView(info, LayoutHelper.createLinear(LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT, 0, 0, 0, 20));
83+
84+ baseError = addField(content, context, LocaleController.getString(R.string.WebPublishBase),
85+ WebPublishConfig.base(), LocaleController.getString(R.string.WebPublishBaseHint),
86+ InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI, EditorInfo.IME_ACTION_NEXT, false);
87+ baseField = lastField;
88+
89+ tokenError = addField(content, context, LocaleController.getString(R.string.WebPublishToken),
90+ WebPublishConfig.token(), null,
91+ InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD, EditorInfo.IME_ACTION_NEXT, false);
92+ tokenField = lastField;
93+ tokenField.setTransformationMethod(PasswordTransformationMethod.getInstance());
94+
95+ TextView advancedToggle = new TextView(context);
96+ advancedToggle.setTextSize(14);
97+ advancedToggle.setPadding(0, AndroidUtilities.dp(16), 0, AndroidUtilities.dp(4));
98+ advancedToggle.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlueText4));
99+ content.addView(advancedToggle, LayoutHelper.createLinear(LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT));
100+
101+ final LinearLayout advancedGroup = new LinearLayout(context);
102+ advancedGroup.setOrientation(LinearLayout.VERTICAL);
103+ advancedGroup.setVisibility(View.GONE);
104+ content.addView(advancedGroup, LayoutHelper.createLinear(LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT));
105+
106+ final Runnable updateToggle = () -> {
107+ boolean open = advancedGroup.getVisibility() == View.VISIBLE;
108+ advancedToggle.setText((open ? "▾ " : "▸ ") + LocaleController.getString(R.string.WebPublishAdvanced));
109+ };
110+ updateToggle.run();
111+ advancedToggle.setOnClickListener(v -> {
112+ advancedGroup.setVisibility(advancedGroup.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE);
113+ updateToggle.run();
114+ });
115+
116+ templateError = addField(advancedGroup, context, LocaleController.getString(R.string.WebPublishTemplate),
117+ WebPublishConfig.template(), null,
118+ InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE, EditorInfo.IME_ACTION_NONE, true);
119+ templateField = lastField;
120+
121+ return fragmentView;
122+ }
123+
124+ private EditTextBoldCursor lastField;
125+
126+ private TextView addField(LinearLayout content, Context context, String label, String value, String hint, int inputType, int imeOptions, boolean multiline) {
127+ OutlineEditText outline = new OutlineEditText(context);
128+ outline.setHint(label);
129+ EditTextBoldCursor field = outline.getEditText();
130+ field.setText(value != null ? value : "");
131+ if (hint != null) {
132+ field.setHint(hint);
133+ }
134+ if (multiline) {
135+ field.setSingleLine(false);
136+ field.setMaxLines(Integer.MAX_VALUE);
137+ field.setMinLines(2);
138+ field.setGravity(LocaleController.isRTL ? Gravity.RIGHT : Gravity.LEFT);
139+ field.setHorizontallyScrolling(false);
140+ }
141+ field.setInputType(inputType);
142+ field.setImeOptions(imeOptions);
143+ int pad = AndroidUtilities.dp(16);
144+ field.setPadding(AndroidUtilities.dp(15), pad, AndroidUtilities.dp(15), pad);
145+ content.addView(outline, LayoutHelper.createLinear(LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT, 0, 0, 0, AndroidUtilities.dp(2)));
146+ lastField = field;
147+ TextView error = makeError(context);
148+ content.addView(error, LayoutHelper.createLinear(LayoutHelper.MATCH_PARENT, LayoutHelper.WRAP_CONTENT, 0, AndroidUtilities.dp(4), 0, AndroidUtilities.dp(10)));
149+ return error;
150+ }
151+
152+ private TextView makeError(Context context) {
153+ TextView error = new TextView(context);
154+ error.setTextSize(13);
155+ error.setTextColor(Theme.getColor(Theme.key_text_RedRegular));
156+ error.setVisibility(View.GONE);
157+ return error;
158+ }
159+
160+ private static void showError(TextView view, String message) {
161+ if (message == null) {
162+ view.setVisibility(View.GONE);
163+ } else {
164+ view.setText(message);
165+ view.setVisibility(View.VISIBLE);
166+ }
167+ }
168+
169+ private void save() {
170+ String base = baseField.getText().toString();
171+ String token = tokenField.getText().toString().trim();
172+ String template = templateField.getText().toString();
173+
174+ Map<String, String> errors = WebPublishConfig.validateValues(base, token, template);
175+ showError(baseError, errors.get(WebPublishConfig.FIELD_BASE));
176+ showError(tokenError, errors.get(WebPublishConfig.FIELD_TOKEN));
177+ showError(templateError, errors.get(WebPublishConfig.FIELD_TEMPLATE));
178+ if (!errors.isEmpty()) {
179+ return;
180+ }
181+
182+ SharedPreferences prefs = MessagesController.getGlobalMainSettings();
183+ SharedPreferences.Editor editor = prefs.edit();
184+ editor.putString(WebPublishConfig.PREF_BASE, base.trim());
185+ editor.putString(WebPublishConfig.PREF_TOKEN, token);
186+ editor.putString(WebPublishConfig.PREF_TEMPLATE, template);
187+ editor.apply();
188+
189+ if (getParentActivity() != null) {
190+ android.widget.Toast.makeText(getParentActivity(), LocaleController.getString(R.string.WebPublishSaved), android.widget.Toast.LENGTH_SHORT).show();
191+ }
192+ finishFragment();
193+ }
194+}
TMessagesProj/src/main/res/drawable/msg_publish.xmlnew file+49
@@ -0,0 +1,49 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Publish to web: memo with pencil, traced from wirte.svg. -->
3+<vector xmlns:android="http://schemas.android.com/apk/res/android"
4+ android:width="24dp"
5+ android:height="24dp"
6+ android:viewportWidth="512"
7+ android:viewportHeight="512">
8+
9+ <group
10+ android:translateX="99.42208862304688"
11+ android:translateY="16.739303588867188">
12+ <path
13+ android:fillColor="#FFFFFF"
14+ android:pathData="M0 0 C1.34 -0 2.67 -0.01 4.01 -0.01 C7.66 -0.02 11.31 -0.02 14.95 -0.02 C18.9 -0.01 22.84 -0.02 26.79 -0.03 C34.5 -0.04 42.22 -0.04 49.94 -0.04 C56.22 -0.04 62.49 -0.04 68.77 -0.05 C69.66 -0.05 70.56 -0.05 71.48 -0.05 C73.29 -0.05 75.11 -0.05 76.93 -0.05 C93.96 -0.06 110.98 -0.06 128.01 -0.05 C143.57 -0.05 159.14 -0.06 174.7 -0.08 C190.7 -0.1 206.69 -0.11 222.69 -0.1 C231.66 -0.1 240.64 -0.1 249.61 -0.12 C257.25 -0.13 264.89 -0.13 272.53 -0.12 C276.42 -0.11 280.32 -0.11 284.21 -0.12 C287.78 -0.13 291.36 -0.13 294.93 -0.12 C296.82 -0.11 298.71 -0.12 300.6 -0.14 C316.12 -0.04 328.94 4.1 340.26 14.89 C349.96 24.81 355.67 36.41 355.73 50.48 C355.74 52.36 355.74 52.36 355.75 54.27 C355.75 55.65 355.75 57.03 355.75 58.41 C355.76 59.87 355.77 61.33 355.77 62.79 C355.8 67.57 355.81 72.35 355.82 77.13 C355.82 78.78 355.82 80.43 355.83 82.08 C355.85 89.82 355.86 97.56 355.87 105.3 C355.88 114.23 355.91 123.16 355.95 132.09 C355.98 139 355.99 145.91 355.99 152.81 C356 156.94 356.01 161.06 356.03 165.18 C356.05 169.06 356.06 172.94 356.05 176.82 C356.05 178.24 356.05 179.66 356.07 181.08 C356.19 194.39 356.19 194.39 351.58 199.26 C347.23 203.2 343.66 203.89 337.98 203.65 C333.46 202.92 330.76 200.56 327.77 197.26 C325.48 193.4 325.45 189.75 325.45 185.33 C325.44 183.51 325.44 183.51 325.43 181.65 C325.43 180.31 325.44 178.97 325.44 177.62 C325.43 176.21 325.43 174.79 325.43 173.37 C325.42 170.33 325.41 167.28 325.41 164.23 C325.4 159.4 325.39 154.58 325.37 149.75 C325.36 148.1 325.36 146.45 325.35 144.8 C325.35 143.97 325.34 143.14 325.34 142.29 C325.3 131.06 325.26 119.83 325.25 108.6 C325.24 101.02 325.22 93.44 325.18 85.86 C325.16 81.85 325.15 77.85 325.16 73.84 C325.16 70.07 325.15 66.29 325.12 62.52 C325.11 61.14 325.11 59.76 325.12 58.38 C325.16 50.07 325.07 43.19 319.2 36.75 C314.38 32.36 309.24 31.01 302.85 31 C301.72 31 300.6 30.99 299.44 30.98 C297.6 30.99 297.6 30.99 295.72 30.99 C293.76 30.99 293.76 30.99 291.75 30.98 C288.12 30.97 284.49 30.97 280.86 30.97 C276.94 30.97 273.02 30.96 269.11 30.95 C261.43 30.93 253.76 30.93 246.08 30.93 C239.85 30.93 233.61 30.92 227.37 30.92 C209.68 30.9 192 30.89 174.31 30.89 C173.35 30.89 172.4 30.89 171.42 30.89 C170.46 30.89 169.51 30.89 168.53 30.89 C153.05 30.89 137.58 30.87 122.11 30.84 C106.22 30.82 90.33 30.8 74.45 30.8 C65.53 30.8 56.61 30.8 47.69 30.78 C40.09 30.76 32.5 30.75 24.91 30.77 C21.03 30.77 17.16 30.77 13.29 30.76 C9.09 30.74 4.89 30.75 0.69 30.77 C-0.54 30.76 -1.76 30.75 -3.02 30.74 C-10.86 30.8 -16.3 31.77 -22.45 36.97 C-23.11 37.8 -23.11 37.8 -23.8 38.64 C-24.26 39.18 -24.72 39.72 -25.2 40.28 C-28.09 44.95 -28.67 49.53 -28.68 54.94 C-28.68 55.59 -28.68 56.24 -28.69 56.91 C-28.69 59.08 -28.69 61.26 -28.68 63.43 C-28.69 65.01 -28.69 66.58 -28.69 68.16 C-28.7 72.48 -28.7 76.81 -28.7 81.14 C-28.7 85.8 -28.7 90.47 -28.71 95.14 C-28.72 104.28 -28.73 113.42 -28.72 122.56 C-28.72 129.99 -28.72 137.42 -28.73 144.85 C-28.73 145.91 -28.73 146.97 -28.73 148.06 C-28.73 150.2 -28.73 152.35 -28.73 154.5 C-28.74 174.64 -28.74 194.78 -28.74 214.93 C-28.73 233.36 -28.74 251.79 -28.76 270.22 C-28.78 289.14 -28.79 308.06 -28.79 326.98 C-28.78 337.61 -28.79 348.23 -28.8 358.85 C-28.81 367.9 -28.81 376.94 -28.8 385.99 C-28.8 390.6 -28.79 395.22 -28.81 399.83 C-28.82 404.06 -28.81 408.28 -28.8 412.51 C-28.8 414.03 -28.8 415.56 -28.81 417.09 C-29.06 429.6 -29.06 429.6 -23.55 440.5 C-22.56 441.31 -22.56 441.31 -21.55 442.14 C-20.89 442.69 -20.23 443.24 -19.55 443.82 C-14.13 447.49 -9.32 447.55 -2.97 447.52 C-2.02 447.52 -1.08 447.52 -0.1 447.53 C1.94 447.53 3.99 447.54 6.04 447.54 C9.29 447.53 12.53 447.55 15.78 447.56 C25 447.61 34.23 447.63 43.45 447.64 C49.1 447.65 54.75 447.67 60.39 447.71 C62.54 447.72 64.69 447.72 66.84 447.71 C69.85 447.71 72.86 447.72 75.87 447.75 C76.75 447.74 77.63 447.73 78.54 447.72 C84.38 447.8 87.76 448.93 92.58 452.26 C95.54 455.38 96.82 459 97.08 463.26 C96.82 467.52 95.54 471.14 92.58 474.26 C86.89 478.2 82.74 478.84 75.9 478.76 C74.96 478.77 74.01 478.77 73.04 478.78 C69.92 478.79 66.81 478.77 63.7 478.76 C61.52 478.76 59.34 478.76 57.16 478.76 C52.6 478.76 48.03 478.75 43.47 478.73 C37.65 478.7 31.83 478.7 26.01 478.71 C21.51 478.72 17.01 478.71 12.5 478.7 C10.36 478.7 8.22 478.7 6.07 478.7 C-13.82 478.72 -30 477.1 -45.13 462.74 C-55.29 451.75 -59.54 438.26 -59.55 423.46 C-59.55 422.81 -59.55 422.16 -59.56 421.5 C-59.56 419.33 -59.56 417.16 -59.56 414.99 C-59.56 413.42 -59.56 411.84 -59.56 410.27 C-59.57 405.95 -59.57 401.63 -59.57 397.31 C-59.57 392.65 -59.57 387.99 -59.58 383.33 C-59.59 374.21 -59.59 365.08 -59.59 355.95 C-59.59 348.53 -59.59 341.11 -59.59 333.69 C-59.6 312.65 -59.61 291.61 -59.61 270.57 C-59.61 269.43 -59.61 268.3 -59.61 267.13 C-59.61 265.99 -59.61 264.86 -59.61 263.69 C-59.61 245.29 -59.62 226.88 -59.63 208.48 C-59.64 189.58 -59.65 170.68 -59.65 151.78 C-59.65 141.17 -59.65 130.56 -59.66 119.95 C-59.67 110.92 -59.68 101.89 -59.67 92.86 C-59.67 88.25 -59.67 83.64 -59.67 79.04 C-59.68 74.81 -59.68 70.59 -59.67 66.37 C-59.67 64.85 -59.67 63.32 -59.68 61.8 C-59.74 43.23 -56.64 28.31 -43.69 14.4 C-31.36 2.52 -16.46 -0.05 0 0 Z " />
15+ </group>
16+
17+ <group
18+ android:translateX="454.0"
19+ android:translateY="268.0">
20+ <path
21+ android:fillColor="#FFFFFF"
22+ android:pathData="M0 0 C0.61 0.52 1.23 1.04 1.86 1.58 C13.13 11.85 17.3 25.5 18 40.26 C18.02 60.33 7.95 73.28 -5.53 86.83 C-6.79 88.1 -8.06 89.36 -9.32 90.63 C-10.67 91.98 -12.01 93.33 -13.36 94.69 C-16.99 98.35 -20.64 102 -24.28 105.65 C-26.56 107.93 -28.84 110.21 -31.11 112.5 C-39.07 120.48 -47.03 128.45 -54.99 136.42 C-62.41 143.84 -69.81 151.27 -77.2 158.7 C-83.56 165.1 -89.93 171.48 -96.31 177.86 C-100.12 181.67 -103.92 185.48 -107.71 189.3 C-111.27 192.88 -114.84 196.46 -118.43 200.03 C-119.74 201.34 -121.04 202.65 -122.34 203.97 C-131.67 213.4 -139.17 218.77 -152.42 220.89 C-153.28 221.03 -154.14 221.18 -155.03 221.33 C-157.75 221.8 -160.47 222.24 -163.19 222.69 C-165.02 223 -166.86 223.31 -168.69 223.62 C-172.79 224.31 -176.88 224.99 -180.98 225.67 C-182.79 225.97 -184.6 226.27 -186.41 226.58 C-195.56 228.06 -203.8 226.87 -211.69 221.88 C-218.17 216.86 -221.74 209.97 -223 202 C-223.25 195.59 -222.48 189.6 -221.43 183.29 C-221.26 182.28 -221.09 181.27 -220.92 180.23 C-220.57 178.11 -220.22 175.99 -219.86 173.87 C-219.32 170.64 -218.8 167.42 -218.28 164.2 C-217.94 162.12 -217.59 160.05 -217.24 157.98 C-217.09 157.03 -216.94 156.07 -216.79 155.09 C-214.32 140.96 -206.66 132.94 -196.82 123.2 C-195.46 121.84 -194.1 120.48 -192.75 119.12 C-189.08 115.44 -185.4 111.78 -181.72 108.12 C-177.86 104.27 -174.01 100.42 -170.16 96.56 C-163.69 90.1 -157.21 83.64 -150.73 77.19 C-143.25 69.74 -135.78 62.28 -128.32 54.81 C-121.12 47.61 -113.92 40.42 -106.71 33.23 C-103.65 30.18 -100.6 27.13 -97.54 24.07 C-93.94 20.47 -90.33 16.87 -86.72 13.29 C-85.4 11.97 -84.08 10.66 -82.76 9.34 C-59.78 -13.69 -26.95 -24.03 0 0 Z M-62 31 C-63.26 32.24 -63.26 32.24 -64.54 33.5 C-66.38 35.32 -68.2 37.15 -70 39 C-65.97 43.83 -61.76 48.38 -57.29 52.81 C-56.37 53.73 -56.37 53.73 -55.43 54.66 C-53.5 56.59 -51.56 58.51 -49.62 60.44 C-48.3 61.75 -46.98 63.07 -45.66 64.39 C-42.44 67.6 -39.22 70.8 -36 74 C-34.89 73.06 -33.79 72.12 -32.68 71.17 C-32.07 70.65 -31.45 70.12 -30.82 69.58 C-28.21 67.32 -25.76 64.93 -23.31 62.5 C-22.4 61.6 -21.49 60.71 -20.55 59.78 C-15.19 53.93 -12.42 48.57 -11.69 40.62 C-12.33 33.18 -14.51 27.25 -20 22 C-36.87 10.86 -49.07 18.08 -62 31 Z M-95.64 64.3 C-96.42 65.03 -97.21 65.77 -98.01 66.52 C-98.88 67.34 -99.74 68.15 -100.63 68.99 C-101.99 70.27 -101.99 70.27 -103.38 71.58 C-111.82 79.55 -120.17 87.6 -128.49 95.7 C-134.07 101.12 -139.68 106.51 -145.37 111.82 C-179.71 142.7 -179.71 142.7 -193.06 185.52 C-193.16 188.69 -193.11 191.83 -193 195 C-193 195.66 -193 196.32 -193 197 C-189.33 196.69 -185.67 196.35 -182 196 C-180.87 195.9 -179.73 195.8 -178.57 195.7 C-156.47 193.47 -144.11 186.79 -128.88 170.41 C-126.37 167.75 -123.79 165.16 -121.22 162.55 C-116.4 157.65 -111.64 152.69 -106.9 147.7 C-101.49 142.01 -96.01 136.4 -90.52 130.78 C-79.27 119.27 -68.1 107.66 -57 96 C-61.48 90.69 -61.48 90.69 -66.24 85.64 C-66.79 85.07 -67.35 84.51 -67.92 83.94 C-68.5 83.36 -69.08 82.79 -69.68 82.19 C-70.29 81.58 -70.9 80.97 -71.53 80.34 C-72.8 79.06 -74.08 77.78 -75.36 76.51 C-77.33 74.55 -79.29 72.58 -81.25 70.61 C-82.5 69.37 -83.74 68.12 -84.99 66.88 C-85.58 66.29 -86.17 65.7 -86.77 65.09 C-91.16 60.41 -91.16 60.41 -95.64 64.3 Z " />
23+ </group>
24+
25+ <group
26+ android:translateX="147.91258239746094"
27+ android:translateY="185.4920654296875">
28+ <path
29+ android:fillColor="#FFFFFF"
30+ android:pathData="M0 0 C1.33 -0.01 1.33 -0.01 2.69 -0.02 C5.65 -0.03 8.61 -0.02 11.58 -0.01 C13.7 -0.02 15.83 -0.02 17.96 -0.03 C23.73 -0.05 29.51 -0.04 35.28 -0.03 C41.33 -0.02 47.37 -0.03 53.42 -0.03 C63.57 -0.04 73.72 -0.03 83.87 -0.01 C95.6 0.01 107.33 0 119.07 -0.02 C129.14 -0.04 139.22 -0.04 149.29 -0.03 C155.31 -0.02 161.33 -0.02 167.34 -0.04 C173 -0.05 178.66 -0.04 184.32 -0.02 C186.39 -0.01 188.47 -0.01 190.54 -0.02 C193.38 -0.03 196.21 -0.02 199.05 0 C199.87 -0.01 200.69 -0.02 201.53 -0.03 C207.16 0.05 210.43 1.31 215.09 4.51 C218.27 8.15 219.43 12.28 219.21 17.07 C218.38 21.94 216.02 25.53 212.09 28.51 C207.06 30.6 202.67 30.79 197.33 30.77 C196.46 30.77 195.59 30.78 194.69 30.78 C191.78 30.79 188.87 30.79 185.96 30.78 C183.88 30.79 181.79 30.79 179.7 30.8 C174.03 30.81 168.37 30.81 162.7 30.81 C157.97 30.81 153.24 30.81 148.5 30.82 C137.34 30.83 126.18 30.83 115.02 30.82 C103.5 30.82 91.99 30.83 80.47 30.85 C70.58 30.87 60.69 30.87 50.81 30.87 C44.9 30.87 39 30.87 33.09 30.89 C27.54 30.9 21.99 30.9 16.43 30.88 C14.4 30.88 12.36 30.88 10.32 30.89 C7.54 30.9 4.76 30.89 1.98 30.88 C0.77 30.89 0.77 30.89 -0.46 30.9 C-5.67 30.85 -9.29 29.95 -13.91 27.51 C-18.41 22.66 -19.27 19.51 -19.19 13.05 C-18.65 8.06 -16.06 5.45 -12.32 2.38 C-7.99 0 -4.85 -0.05 0 0 Z " />
31+ </group>
32+
33+ <group
34+ android:translateX="147.5965118408203"
35+ android:translateY="104.85282897949219">
36+ <path
37+ android:fillColor="#FFFFFF"
38+ android:pathData="M0 0 C0.89 -0 1.78 -0.01 2.69 -0.01 C5.68 -0.02 8.67 -0.03 11.66 -0.03 C13.8 -0.04 15.94 -0.04 18.08 -0.05 C23.89 -0.07 29.71 -0.08 35.53 -0.09 C39.16 -0.09 42.8 -0.1 46.43 -0.1 C57.79 -0.12 69.15 -0.14 80.52 -0.15 C93.64 -0.15 106.77 -0.18 119.89 -0.22 C130.03 -0.25 140.17 -0.27 150.31 -0.27 C156.37 -0.27 162.43 -0.28 168.49 -0.31 C174.19 -0.33 179.89 -0.33 185.58 -0.32 C187.68 -0.32 189.77 -0.33 191.86 -0.34 C194.72 -0.36 197.57 -0.35 200.43 -0.34 C201.25 -0.35 202.08 -0.36 202.94 -0.37 C209.13 -0.3 212.63 1.57 217.22 5.83 C219.79 10.85 219.85 15.62 218.28 20.96 C215.96 24.9 213.47 27.11 209.4 29.15 C206.56 29.25 203.74 29.29 200.89 29.29 C199.55 29.3 199.55 29.3 198.19 29.31 C195.18 29.32 192.17 29.32 189.17 29.32 C187.02 29.33 184.87 29.34 182.72 29.34 C176.87 29.36 171.03 29.37 165.18 29.38 C161.52 29.39 157.87 29.39 154.22 29.4 C142.8 29.42 131.38 29.43 119.96 29.44 C106.77 29.45 93.57 29.48 80.38 29.52 C70.19 29.55 60 29.56 49.8 29.56 C43.71 29.57 37.62 29.58 31.53 29.6 C25.81 29.62 20.08 29.63 14.35 29.62 C12.24 29.62 10.14 29.62 8.04 29.64 C5.17 29.66 2.3 29.65 -0.57 29.63 C-1.82 29.65 -1.82 29.65 -3.1 29.67 C-8.39 29.61 -11.4 28.66 -15.6 25.15 C-18.92 20.39 -19.37 15.78 -18.6 10.15 C-16.95 6.44 -14.65 3.81 -11.6 1.15 C-7.72 -0.15 -4.04 -0 0 0 Z " />
39+ </group>
40+
41+ <group
42+ android:translateX="144.5723419189453"
43+ android:translateY="266.85955810546875">
44+ <path
45+ android:fillColor="#FFFFFF"
46+ android:pathData="M0 0 C0.94 -0.01 1.88 -0.01 2.84 -0.02 C3.87 -0.02 4.9 -0.02 5.95 -0.02 C7.58 -0.03 7.58 -0.03 9.24 -0.04 C12.83 -0.05 16.42 -0.06 20.01 -0.06 C22.5 -0.07 24.99 -0.07 27.49 -0.08 C32.05 -0.09 36.61 -0.1 41.17 -0.1 C47.21 -0.1 53.25 -0.11 59.29 -0.14 C65.77 -0.17 72.25 -0.18 78.73 -0.18 C81.2 -0.19 83.67 -0.19 86.14 -0.21 C89.6 -0.22 93.06 -0.22 96.51 -0.22 C98.04 -0.23 98.04 -0.23 99.61 -0.24 C104.97 -0.21 108.82 0.08 113.43 3.14 C117.87 8.07 118.78 11.09 118.71 17.6 C118.19 22.24 115.77 24.96 112.37 27.95 C109.16 29.92 106.53 30.27 102.86 30.28 C101.45 30.29 101.45 30.29 100.01 30.3 C98.99 30.3 97.96 30.3 96.9 30.3 C95.28 30.31 95.28 30.31 93.62 30.32 C90.03 30.33 86.44 30.34 82.85 30.34 C80.35 30.35 77.86 30.36 75.37 30.36 C70.81 30.37 66.25 30.38 61.69 30.38 C55.65 30.38 49.61 30.4 43.57 30.42 C37.09 30.45 30.61 30.46 24.12 30.46 C21.65 30.47 19.18 30.48 16.71 30.49 C13.26 30.51 9.8 30.5 6.34 30.5 C4.81 30.51 4.81 30.51 3.25 30.52 C-2.11 30.49 -5.97 30.2 -10.57 27.14 C-15.02 22.21 -15.92 19.19 -15.86 12.68 C-15.34 8.04 -12.91 5.32 -9.51 2.33 C-6.3 0.36 -3.67 0.01 0 0 Z " />
47+ </group>
48+
49+</vector>
TMessagesProj/src/main/res/values/strings.xml+9
@@ -11493,4 +11493,13 @@
1149311493 <string name="FolderTabsStyleInfo">Choose how folders appear in the tab bar. Assign an icon to each folder in its settings.</string>
1149411494 <string name="ShareDeleteOriginals">Delete original messages</string>
1149511495 <string name="SendAsSinglePhoto">Send as one photo</string>
11496+ <string name="WebPublish">Publish to web</string>
11497+ <string name="WebPublishSection">Web Publishing</string>
11498+ <string name="WebPublishInfo">Build text from selected messages, upload it with media to the web service in one request, then copy the page link to the clipboard. No local files are kept: media lives only in a temporary cache until the upload finishes.</string>
11499+ <string name="WebPublishBase">Service base URL</string>
11500+ <string name="WebPublishBaseHint">https://example.com</string>
11501+ <string name="WebPublishToken">Upload token</string>
11502+ <string name="WebPublishTemplate">Clipboard template (%q = link)</string>
11503+ <string name="WebPublishAdvanced">Advanced</string>
11504+ <string name="WebPublishSaved">Publish settings saved</string>
1149611505</resources>
gradle.properties+2−2
@@ -13,8 +13,8 @@
1313# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
1414# org.gradle.parallel=true
1515#Sat Mar 12 05:53:50 MSK 2016
16−APP_VERSION_CODE=7106
17−APP_VERSION_NAME=12.10.6
16+APP_VERSION_CODE=7105
17+APP_VERSION_NAME=12.10.5
1818APP_PACKAGE=org.telegram.messenger
1919IS_PRIVATE=false
2020RELEASE_KEY_PASSWORD=android
tools/webpublish_test.pynew file+140
@@ -0,0 +1,140 @@
1+#!/usr/bin/env python3
2+"""Integration test for the "Publish to web" feature (Forkgram Android).
3+
4+Exercises the service contract the app uses:
5+ upload: POST {base}/upload, multipart fields "text" + one "files[]" per file
6+ (filename + content type), header X-Upload-Token
7+ -> {"id": "<id>", "url": "<page url>"}
8+ delete: DELETE <page url>, header X-Upload-Token
9+
10+Flow: upload text + 1 jpg + 1 mp4, assert the page URL opens (GET 200)
11+and contains the text, then delete and assert GET 404/410.
12+
13+Config via env (mirrors the app settings):
14+ WEBPUBLISH_BASE e.g. https://drop.zik.one (required)
15+ UPLOAD_TOKEN upload token (required)
16+
17+Usage:
18+ WEBPUBLISH_BASE=https://drop.zik.one UPLOAD_TOKEN=<token> python3 tools/webpublish_test.py
19+
20+Manual equivalent with curl (same contract):
21+
22+ BASE=https://drop.zik.one ; T=<token>
23+ # 1. upload text + files
24+ curl -s -A 'Forkgram/1.0' -H "X-Upload-Token: $T" \
25+ -F 'text=Steps to reproduce: ...' \
26+ -F 'files[]=@screen.jpg;type=image/jpeg' \
27+ -F 'files[]=@rec.mp4;type=video/mp4' \
28+ "$BASE/upload" | tee /tmp/up.json
29+ LINK=$(python3 -c "import json;print(json.load(open('/tmp/up.json'))['url'])")
30+ # 2. verify
31+ curl -s "$LINK" | grep -o 'Steps to reproduce'
32+ # 3. delete
33+ curl -s -X DELETE -A 'Forkgram/1.0' -H "X-Upload-Token: $T" "$LINK"
34+ curl -s -o /dev/null -w '%{http_code}\n' "$LINK" # expect 404 or 410
35+"""
36+
37+import base64
38+import json
39+import os
40+import random
41+import string
42+import sys
43+import urllib.error
44+import urllib.request
45+
46+USER_AGENT = "Forkgram/1.0"
47+
48+# Minimal 1x1 JPEG (valid SOI..EOI), stdlib-only fixture.
49+TINY_JPG_B64 = (
50+ "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////"
51+ "//////////////////////////////////////////2wBDAf//////////////////////////////////////////"
52+ "//////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAA"
53+ "AAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AK//Z"
54+)
55+# Minimal MP4: ftyp box (isom) + empty mdat. Opaque stores accept it as bytes.
56+TINY_MP4 = (
57+ b"\x00\x00\x00\x18ftypisom\x00\x00\x00\x01isomiso2"
58+ b"\x00\x00\x00\x08free"
59+ b"\x00\x00\x00\x10mdat\x00\x00\x00\x00\x00\x00\x00\x00"
60+)
61+
62+
63+def http(method, url, body=None, headers=None):
64+ req = urllib.request.Request(url, data=body, method=method,
65+ headers={"User-Agent": USER_AGENT, **(headers or {})})
66+ try:
67+ with urllib.request.urlopen(req, timeout=60) as resp:
68+ return resp.status, resp.read(), dict(resp.headers)
69+ except urllib.error.HTTPError as e:
70+ return e.code, e.read(), dict(e.headers)
71+
72+
73+def main():
74+ base = os.environ.get("WEBPUBLISH_BASE", "").strip().rstrip("/")
75+ assert base.startswith(("http://", "https://")), "set WEBPUBLISH_BASE, e.g. https://drop.zik.one"
76+ token = os.environ.get("UPLOAD_TOKEN", "")
77+ assert token, "set UPLOAD_TOKEN"
78+ print(f"base={base}")
79+
80+ workdir = "/tmp/webpublish_test"
81+ os.makedirs(workdir, exist_ok=True)
82+ jpg = os.path.join(workdir, "screen.jpg")
83+ mp4 = os.path.join(workdir, "rec.mp4")
84+ with open(jpg, "wb") as f:
85+ f.write(base64.b64decode(TINY_JPG_B64))
86+ with open(mp4, "wb") as f:
87+ f.write(TINY_MP4)
88+ assert os.path.getsize(jpg) <= 5 * 1024 * 1024
89+ assert os.path.getsize(mp4) <= 20 * 1024 * 1024
90+
91+ text = "test report: steps to reproduce — one, two"
92+ assert len(text.encode()) <= 5 * 1024 * 1024
93+
94+ boundary = "----ForkgramTest" + "".join(random.choices(string.hexdigits, k=16))
95+
96+ def field(name, value):
97+ return (f"--{boundary}\r\n"
98+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
99+ f"{value}\r\n").encode()
100+
101+ def file_field(path, filename, mime):
102+ with open(path, "rb") as f:
103+ payload = f.read()
104+ head = (f"--{boundary}\r\n"
105+ f'Content-Disposition: form-data; name="files[]"; filename="{filename}"\r\n'
106+ f"Content-Type: {mime}\r\n\r\n").encode()
107+ return head + payload + b"\r\n"
108+
109+ body = (field("text", text)
110+ + file_field(jpg, "screen.jpg", "image/jpeg")
111+ + file_field(mp4, "rec.mp4", "video/mp4")
112+ + f"--{boundary}--\r\n".encode())
113+ headers = {"Content-Type": f"multipart/form-data; boundary={boundary}",
114+ "X-Upload-Token": token}
115+ code, raw, _ = http("POST", base + "/upload", body, headers)
116+ assert code in (200, 201), f"upload failed: HTTP {code}: {raw[:200]!r}"
117+ data = json.loads(raw.decode())
118+ link, rid = data.get("url"), data.get("id")
119+ assert link and rid, f"upload response has no url/id: {raw[:200]!r}"
120+ print("page:", link, "id:", rid)
121+
122+ code, raw, _ = http("GET", link)
123+ assert code == 200, f"page GET failed: HTTP {code}"
124+ assert "steps to reproduce" in raw.decode(errors="replace"), "page does not contain the text"
125+ print("GET 200 + text OK")
126+
127+ code, raw, _ = http("DELETE", link, None, {"X-Upload-Token": token})
128+ assert code in (200, 201, 204), f"DELETE failed: HTTP {code}: {raw[:200]!r}"
129+ code, _, _ = http("GET", link)
130+ assert code in (404, 410), f"page still alive after DELETE: HTTP {code}"
131+ print("DELETE OK, GET after delete:", code)
132+ print("ALL INTEGRATION CHECKS PASSED")
133+
134+
135+if __name__ == "__main__":
136+ try:
137+ main()
138+ except AssertionError as e:
139+ print("FAIL:", e, file=sys.stderr)
140+ sys.exit(1)