@@ -0,0 +1,675 @@
1+package org.telegram.messenger.forkgram
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
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
25+import java.util.concurrent.atomic.AtomicReference
26+import kotlin.math.min
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).
33+ * No local files are written except a temporary cache used for the upload,
34+ * which is deleted afterwards (on error too).
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
44+object WebPublishConfig {
46+ const val PREF_BASE = "webpublish_base"
47+ const val PREF_TOKEN = "webpublish_token"
48+ const val PREF_TEMPLATE = "webpublish_template"
50+ const val DEFAULT_TEMPLATE = "%q"
52+ const val FIELD_BASE = "base"
53+ const val FIELD_TOKEN = "token"
54+ const val FIELD_TEMPLATE = "template"
57+ fun prefs(): SharedPreferences {
58+ return MessagesController.getGlobalMainSettings()
62+ fun base(): String = prefs().getString(PREF_BASE, "") ?: ""
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.
70+ fun token(): String = prefs().getString(PREF_TOKEN, "") ?: ""
73+ fun hasToken(): Boolean = token().isNotEmpty()
76+ fun template(): String {
77+ return prefs().getString(PREF_TEMPLATE, DEFAULT_TEMPLATE) ?: DEFAULT_TEMPLATE
81+ * Validate the effective configuration.
82+ * @return map of field name -> human-readable error; empty means valid.
85+ fun validate(): Map<String, String> {
86+ return validateValues(base().trim(), token(), template())
90+ fun validateValues(base: String, token: String, template: String): Map<String, String> {
91+ val errors = LinkedHashMap<String, String>()
94+ errors[FIELD_BASE] = "Service base URL is required"
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"
103+ } catch (e: Exception) {
104+ errors[FIELD_BASE] = "Base URL is not a valid URL"
107+ if (token.isEmpty()) {
108+ errors[FIELD_TOKEN] = "Upload token is required"
110+ if (template.isEmpty()) {
111+ errors[FIELD_TEMPLATE] = "Clipboard template is required (use %q for the link)"
117+ fun isConfigured(): Boolean {
118+ return base().trim().isNotEmpty()
122+ fun uploadUrl(): String = base().trim().trimEnd('/') + "/upload"
125+ fun applyClipboardTemplate(link: String): String {
127+ return if (t.contains("%q")) t.replace("%q", link) else "$t $link"
131+object WebPublishClient {
133+ const val USER_AGENT = "Forkgram/1.0"
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
139+ const val CONNECT_TIMEOUT_MS = 15_000
140+ const val READ_TIMEOUT_MS = 60_000
142+ data class UploadFile(val file: File, val fileName: String, val mimeType: String)
144+ data class Uploaded(val id: String, val url: String)
146+ sealed class Result {
147+ data class Ok(val uploaded: Uploaded) : Result()
148+ data class Fail(val message: String) : Result()
151+ sealed class DeleteResult {
152+ object Ok : DeleteResult()
153+ data class Fail(val message: String) : DeleteResult()
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) {
162+ if (stream == null) return ""
164+ stream.bufferedReader(Charsets.UTF_8).use { it.readText() }
165+ } catch (e: Exception) {
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)"
175+ val suffix = if (!retryAfter.isNullOrEmpty()) " (retry after $retryAfter)" else ""
176+ "Server rate limit exceeded$suffix (429)"
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"
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))
192+ * Upload text + files in one request.
193+ * Response link and id are taken from the "url" and "id" fields.
196+ fun uploadReport(uploadUrl: String, token: String, text: String, files: List<UploadFile>): Result {
197+ val boundary = "----Forkgram${UUID.randomUUID()}"
198+ var conn: HttpURLConnection? = null
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)
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)
218+ val n = input.read(buf)
220+ out.write(buf, 0, n)
223+ out.write("\r\n".toByteArray(Charsets.UTF_8))
225+ out.write("--$boundary--\r\n".toByteArray(Charsets.UTF_8))
228+ val code = conn.responseCode
229+ val retryAfter = try {
230+ conn.getHeaderField("Retry-After")
231+ } catch (e: Exception) {
234+ val body = readStream(conn, code)
235+ if (code in 200..299) {
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")
243+ return Result.Ok(Uploaded(id, url))
244+ } catch (e: Exception) {
245+ return Result.Fail("Server returned an unreadable response")
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) {
257+ return Result.Fail("Network error: ${e.message ?: e.javaClass.simpleName}")
261+ } catch (e: Exception) {
267+ /** Delete a previously uploaded page by its URL. Requires the upload token. */
269+ fun deleteReport(pageUrl: String, token: String): DeleteResult {
270+ var conn: HttpURLConnection? = null
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
283+ return DeleteResult.Fail(httpErrorMessage(code, null))
284+ } catch (e: Exception) {
286+ return DeleteResult.Fail("Network error: ${e.message ?: e.javaClass.simpleName}")
290+ } catch (e: Exception) {
297+object WebPublishMarkdown {
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()
310+ * Orchestrates one publish run on a dedicated worker thread.
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.
317+object WebPublishFlow {
319+ private const val DOWNLOAD_TIMEOUT_SEC = 120L
320+ private const val DOWNLOAD_POLL_MS = 400L
325+ currentAccount: Int,
326+ messages: List<MessageObject>,
327+ fragment: BaseFragment?,
328+ onSuccess: Runnable? = null
330+ val errors = WebPublishConfig.validate()
331+ if (errors.isNotEmpty()) {
332+ notifyError(fragment, errors.values.first())
335+ if (messages.isEmpty()) {
336+ notifyError(fragment, "No messages selected")
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()
346+ val session = PublishSession(currentAccount, snapshot, fragmentRef, onSuccess)
349+ // Terminal step: session is not passed anywhere else, so it is freed here.
350+ }, "webpublish").start()
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()
360+ Toast.makeText(ApplicationLoader.applicationContext, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
366+ fun notifyError(fragment: BaseFragment?, message: String) {
367+ AndroidUtilities.runOnUIThread {
368+ if (BulletinFactory.canShowBulletin(fragment)) {
369+ BulletinFactory.of(fragment).createErrorBulletin(message).show()
371+ Toast.makeText(ApplicationLoader.applicationContext, message, Toast.LENGTH_LONG).show()
376+ private data class PendingMedia(
377+ val fileName: String,
378+ val mimeType: String,
380+ val isImage: Boolean,
381+ val isVideo: Boolean
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.
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?
396+ private val tempDir = File(
397+ File(ApplicationLoader.applicationContext.cacheDir, "webpublish"),
398+ "run_${System.currentTimeMillis()}"
400+ private var tempCounter = 0
401+ private var aborted = false
406+ } catch (e: Exception) {
408+ fail("Publish failed: ${e.message ?: e.javaClass.simpleName}")
414+ private fun fragment(): BaseFragment? = fragmentRef.get()
416+ private fun fail(message: String) {
418+ notifyError(fragment(), message)
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)
433+ val media = resolveMedia(msg)
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")
441+ if (media.isVideo && size > WebPublishClient.VIDEO_MAX_BYTES) {
442+ fail("Video is larger than 20 MB")
448+ val text = textBuilder.toString()
449+ if (text.isEmpty() && pending.isEmpty()) {
450+ fail("Nothing to publish")
453+ if (text.toByteArray(Charsets.UTF_8).size > WebPublishClient.TEXT_MAX_BYTES) {
454+ fail("Resulting text is larger than 5 MB")
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)
465+ AndroidUtilities.runOnUIThread {
468+ } catch (e: Exception) {
474+ is WebPublishClient.Result.Fail -> fail(r.message)
478+ private fun resolveMedia(msg: MessageObject): PendingMedia? {
480+ resolveMediaInternal(msg)
481+ } catch (e: Exception) {
487+ private fun resolveMediaInternal(msg: MessageObject): PendingMedia? {
488+ val loader = FileLoader.getInstance(currentAccount)
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() }
499+ val location = ImageLocation.getForPhoto(biggest, photo) ?: return null
500+ AndroidUtilities.runOnUIThread {
502+ loader.loadFile(location, msg, "jpg", FileLoader.PRIORITY_HIGH, 1)
503+ } catch (e: Exception) {
507+ file = waitForFile {
508+ existingFile(msg.messageOwner?.attachPath)
509+ ?: loader.getPathToMessage(msg.messageOwner)?.takeIf { it.exists() }
512+ fail("Could not download photo")
516+ val staged = stageToTemp(file, "photo", ".jpg") ?: return null
517+ return PendingMedia("photo.jpg", "image/jpeg", staged, isImage = true, isVideo = false)
519+ val document = try {
521+ } catch (e: Exception) {
524+ if (document != null) {
526+ msg.isVideo || MessageObject.isVideoDocument(document)
527+ } catch (e: Exception) {
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() }
535+ AndroidUtilities.runOnUIThread {
537+ loader.loadFile(docAny, msg, FileLoader.PRIORITY_HIGH, 0)
538+ } catch (e: Exception) {
542+ file = waitForFile {
543+ loader.getPathToAttach(docAny, null, false, true)?.takeIf { it.exists() }
544+ ?: loader.getPathToMessage(msg.messageOwner)?.takeIf { it.exists() }
547+ fail("Could not download file")
551+ val originalName = try {
552+ FileLoader.getDocumentFileName(docAny).ifEmpty { "file" }
553+ } catch (e: Exception) {
557+ val m = msg.getMimeType()
558+ if (m.isNullOrEmpty()) guessMime(originalName) else m
559+ } catch (e: Exception) {
560+ guessMime(originalName)
562+ val staged = stageToTemp(file, "file", extensionOf(originalName)) ?: return null
563+ return PendingMedia(sanitizeFileName(originalName), mime, staged, isImage = false, isVideo = isVideo)
568+ private fun existingFile(path: String?): File? {
569+ if (path.isNullOrEmpty()) return null
571+ return if (f.exists() && f.isFile) f else null
574+ private fun waitForFile(find: () -> File?): File? {
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")
588+ val nc = NotificationCenter.getInstance(currentAccount)
589+ nc.addObserver(observer, NotificationCenter.fileLoaded)
590+ nc.addObserver(observer, NotificationCenter.fileLoadFailed)
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 }
601+ Thread.sleep(DOWNLOAD_POLL_MS)
602+ } catch (e: InterruptedException) {
608+ nc.removeObserver(observer, NotificationCenter.fileLoaded)
609+ nc.removeObserver(observer, NotificationCenter.fileLoadFailed)
613+ private fun stageToTemp(source: File, prefix: String, ext: String): File? {
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)
621+ val n = input.read(buf)
623+ output.write(buf, 0, n)
628+ } catch (e: Exception) {
630+ fail("Could not prepare file for upload")
635+ private fun deleteTemp() {
637+ if (tempDir.exists()) tempDir.deleteRecursively()
638+ } catch (e: Exception) {
643+ private fun guessMime(name: String): String {
644+ val lower = name.lowercase()
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"
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"
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))