on main help sign in
Forkgram 12.10.5 snapshot
1f24641github-actions[bot]
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;
191import org.telegram.messenger.forkgram.ExtractMediaFromPreview;
191import org.telegram.messenger.forkgram.ExtractMediaFromPreview;
192import org.telegram.messenger.forkgram.FormattingMenu;
192import org.telegram.messenger.forkgram.FormattingMenu;
193import org.telegram.messenger.forkgram.MediaSpoiler;
193import org.telegram.messenger.forkgram.MediaSpoiler;
194+import org.telegram.messenger.forkgram.WebPublishConfig;
195+import org.telegram.messenger.forkgram.WebPublishFlow;
194import org.telegram.messenger.support.LongSparseIntArray;
196import org.telegram.messenger.support.LongSparseIntArray;
195import org.telegram.messenger.utils.FBool;
197import org.telegram.messenger.utils.FBool;
196import org.telegram.messenger.utils.OnPostDrawView;
198import org.telegram.messenger.utils.OnPostDrawView;
@@ -1794,6 +1796,7 @@ public class ChatActivity extends BaseFragment implements
1794 private final static int forward = 11;
1796 private final static int forward = 11;
1795 private final static int forward_anonym = 111;
1797 private final static int forward_anonym = 111;
1796 private final static int select_between = 112;
1798 private final static int select_between = 112;
1799+ private final static int publish_web = 113;
1797 private final static int delete = 12;
1800 private final static int delete = 12;
1798 private final static int chat_enc_timer = 13;
1801 private final static int chat_enc_timer = 13;
1799 private final static int chat_menu_attach = 14;
1802 private final static int chat_menu_attach = 14;
@@ -3961,6 +3964,8 @@ public class ChatActivity extends BaseFragment implements
3961 showAnonymShareAlert(false);
3964 showAnonymShareAlert(false);
3962 } else if (id == share) {
3965 } else if (id == share) {
3963 share();
3966 share();
3967+ } else if (id == publish_web) {
3968+ publishSelectedToWeb();
3964 } else if (id == open_direct) {
3969 } else if (id == open_direct) {
3965 if (currentChat == null) return;
3970 if (currentChat == null) return;
3966 presentFragment(ChatActivity.of(-currentChat.linked_monoforum_id));
3971 presentFragment(ChatActivity.of(-currentChat.linked_monoforum_id));
@@ -10658,6 +10663,9 @@ public class ChatActivity extends BaseFragment implements
10658 actionModeViews.add(actionMode.addItemWithWidth(forward_anonym, R.drawable.ic_ab_forward_anonym, dp(48), LocaleController.getString(R.string.Forward)));
10663 actionModeViews.add(actionMode.addItemWithWidth(forward_anonym, R.drawable.ic_ab_forward_anonym, dp(48), LocaleController.getString(R.string.Forward)));
10659 }
10664 }
10660 actionModeViews.add(actionMode.addItemWithWidth(share, R.drawable.msg_shareout, dp(48), LocaleController.getString(R.string.ShareFile)));
10665 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+ }
10661 actionModeViews.add(actionMode.addItemWithWidth(delete, R.drawable.msg_delete, dp(48), LocaleController.getString(R.string.Delete)));
10669 actionModeViews.add(actionMode.addItemWithWidth(delete, R.drawable.msg_delete, dp(48), LocaleController.getString(R.string.Delete)));
10662 } else {
10670 } else {
10663 actionModeViews.add(actionMode.addItemWithWidth(edit, R.drawable.msg_edit, dp(48), LocaleController.getString(R.string.Edit)));
10671 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
12679 updateSelectedMessageReactions();
12687 updateSelectedMessageReactions();
12680 }
12688 }
12681
12689
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+
12682 private void openForward(boolean fromActionBar) {
12705 private void openForward(boolean fromActionBar) {
12683 if (isPeerNoForwards() || hasSelectedNoforwardsMessage()) {
12706 if (isPeerNoForwards() || hasSelectedNoforwardsMessage()) {
12684 // We should update text if user changed locale without re-opening chat activity
12707 // 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;
46import org.telegram.messenger.forkgram.HiddenAccountHelper;
46import org.telegram.messenger.forkgram.HiddenAccountHelper;
47import org.telegram.messenger.forkgram.LinkReplacements;
47import org.telegram.messenger.forkgram.LinkReplacements;
48import org.telegram.messenger.forkgram.SettingsBackup;
48import org.telegram.messenger.forkgram.SettingsBackup;
49+import org.telegram.messenger.forkgram.WebPublishConfig;
49import org.telegram.ui.ActionBar.ActionBar;
50import org.telegram.ui.ActionBar.ActionBar;
50import org.telegram.ui.ActionBar.ActionBarMenu;
51import org.telegram.ui.ActionBar.ActionBarMenu;
51import org.telegram.ui.ActionBar.ActionBarMenuItem;
52import org.telegram.ui.ActionBar.ActionBarMenuItem;
@@ -152,6 +153,8 @@ public class ForkSettingsActivity extends BaseFragment {
152
153
153 public static final int ID_LASTFM_LOGIN = 90;
154 public static final int ID_LASTFM_LOGIN = 90;
154
155
156+ public static final int ID_WEB_PUBLISH = 101;
157+
155 public static final int ID_EXPORT_SETTINGS = 95;
158 public static final int ID_EXPORT_SETTINGS = 95;
156 public static final int ID_IMPORT_SETTINGS = 96;
159 public static final int ID_IMPORT_SETTINGS = 96;
157
160
@@ -331,6 +334,14 @@ public class ForkSettingsActivity extends BaseFragment {
331 return hiddenCount > 0 ? Integer.toString(hiddenCount) : LocaleController.getString(R.string.PasswordOff);
334 return hiddenCount > 0 ? Integer.toString(hiddenCount) : LocaleController.getString(R.string.PasswordOff);
332 }
335 }
333
336
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+
334 @Override
345 @Override
335 public View createView(Context context) {
346 public View createView(Context context) {
336 actionBar.setBackButtonImage(R.drawable.ic_ab_back);
347 actionBar.setBackButtonImage(R.drawable.ic_ab_back);
@@ -717,6 +728,10 @@ public class ForkSettingsActivity extends BaseFragment {
717 items.add(UItem.asShadow(null));
728 items.add(UItem.asShadow(null));
718 }
729 }
719
730
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+
720 items.add(UItem.asHeader(LocaleController.getString(R.string.ForkSectionBackup)));
735 items.add(UItem.asHeader(LocaleController.getString(R.string.ForkSectionBackup)));
721 items.add(UItem.asSettingsCell(ID_EXPORT_SETTINGS, LocaleController.getString(R.string.ExportSettings), ""));
736 items.add(UItem.asSettingsCell(ID_EXPORT_SETTINGS, LocaleController.getString(R.string.ExportSettings), ""));
722 items.add(UItem.asSettingsCell(ID_IMPORT_SETTINGS, LocaleController.getString(R.string.ImportSettings), ""));
737 items.add(UItem.asSettingsCell(ID_IMPORT_SETTINGS, LocaleController.getString(R.string.ImportSettings), ""));
@@ -915,6 +930,9 @@ public class ForkSettingsActivity extends BaseFragment {
915 } else if (id == ID_LASTFM_LOGIN) {
930 } else if (id == ID_LASTFM_LOGIN) {
916 presentFragment(new LastFmLoginActivity());
931 presentFragment(new LastFmLoginActivity());
917
932
933+ } else if (id == ID_WEB_PUBLISH) {
934+ presentFragment(new WebPublishSettingsActivity());
935+
918 } else if (id == ID_EXPORT_SETTINGS) {
936 } else if (id == ID_EXPORT_SETTINGS) {
919 exportSettings();
937 exportSettings();
920 } else if (id == ID_IMPORT_SETTINGS) {
938 } 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 @@
11493 <string name="FolderTabsStyleInfo">Choose how folders appear in the tab bar. Assign an icon to each folder in its settings.</string>
11493 <string name="FolderTabsStyleInfo">Choose how folders appear in the tab bar. Assign an icon to each folder in its settings.</string>
11494 <string name="ShareDeleteOriginals">Delete original messages</string>
11494 <string name="ShareDeleteOriginals">Delete original messages</string>
11495 <string name="SendAsSinglePhoto">Send as one photo</string>
11495 <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>
11496</resources>
11505</resources>
gradle.properties+2−2
@@ -13,8 +13,8 @@
13# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
14# org.gradle.parallel=true
14# org.gradle.parallel=true
15#Sat Mar 12 05:53:50 MSK 2016
15#Sat Mar 12 05:53:50 MSK 2016
16−APP_VERSION_CODE=7106
16+APP_VERSION_CODE=7105
17−APP_VERSION_NAME=12.10.6
17+APP_VERSION_NAME=12.10.5
18APP_PACKAGE=org.telegram.messenger
18APP_PACKAGE=org.telegram.messenger
19IS_PRIVATE=false
19IS_PRIVATE=false
20RELEASE_KEY_PASSWORD=android
20RELEASE_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)