@@ -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+}