From 158c322cda8bef72046959afc3ca487a5d5df2f8 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Mon, 19 May 2025 23:12:43 +0300 Subject: [PATCH] Add option to report errors via e-mail --- README.md | 14 +++ app/build.gradle | 3 +- app/src/main/AndroidManifest.xml | 10 ++ .../network/TikTokDownloadRemoteSource.kt | 35 ++++--- .../exceptions/CaptchaRequiredException.kt | 5 +- .../data/network/exceptions/HtmlException.kt | 1 + .../network/exceptions/NetworkException.kt | 4 +- .../network/exceptions/ParsingException.kt | 4 +- .../exceptions/VideoDeletedException.kt | 4 +- .../exceptions/VideoPrivateException.kt | 4 +- .../converter/ActualVideoPageUrlConverter.kt | 10 +- .../converter/VideoFileUrlConverter.kt | 28 ++++-- .../errortracking/ErrorTracer.kt | 99 +++++++++++++++++++ .../errortracking/SendErrorsAsEmail.kt | 38 +++++++ .../ui/main/settings/SettingsFragment.kt | 38 +++++++ app/src/main/res/layout/fragment_settings.xml | 43 +++++--- app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/file_paths.xml | 6 ++ 19 files changed, 308 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/org/fnives/tiktokdownloader/errortracking/ErrorTracer.kt create mode 100644 app/src/main/java/org/fnives/tiktokdownloader/errortracking/SendErrorsAsEmail.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/README.md b/README.md index 4b7af97..710bac6 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,17 @@ Click on the image for the demo video Here is another Secret Video QR code just for the curious minded ![QR Code for Secret](secret_video_qr_code.png) + + +## Note to self + +When checking error messages, `jq` can be a life saver. + +Example getting the stack trace for a specific error message: + +`jq -r '.[].errors[] | select(.message == "Parsing Error") | .stacktrace' errors.json` + +Or getting all htmls if an error happened in the json: +`jq '.[] | select(.errors[]?.message == "Parsing Error") | .errors[].html'` + +> Use -r when you want to avoid the JSON string quotes around output. \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 1df045c..b47df98 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,7 +102,8 @@ dependencies { def glide_version = "4.15.1" implementation "com.github.bumptech.glide:glide:$glide_version" -// kapt "com.github.bumptech.glide:compiler:$glide_version" + + implementation 'com.google.code.gson:gson:2.12.1' def okhttp_version = "4.12.0" implementation "com.squareup.retrofit2:retrofit:2.9.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 196f425..e4ff8f5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt index 9e35990..2d9f385 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/TikTokDownloadRemoteSource.kt @@ -3,6 +3,7 @@ package org.fnives.tiktokdownloader.data.network import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import org.fnives.tiktokdownloader.errortracking.ErrorTracer import org.fnives.tiktokdownloader.Logger import org.fnives.tiktokdownloader.data.model.VideoInPending import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile @@ -28,6 +29,7 @@ class TikTokDownloadRemoteSource( withContext(Dispatchers.IO) { cookieStore.clear() wrapIntoProperException { + ErrorTracer.startErrorTransaction(videoInPending.url) delay(delayBeforeRequest) // added just so captcha trigger may not happen val actualUrl = service.getContentActualUrlAndCookie(videoInPending.url) val videoUrl: VideoFileUrl @@ -43,19 +45,26 @@ class TikTokDownloadRemoteSource( } Logger.logMessage("videoFileUrl found = ${videoUrl.videoFileUrl}") delay(delayBeforeRequest) // added just so captcha trigger may not happen - val response = service.getVideo(videoUrl.videoFileUrl) + try { + val response = service.getVideo(videoUrl.videoFileUrl) + ErrorTracer.cancelErrorTransaction() - VideoInSavingIntoFile( - id = videoInPending.id, - url = videoInPending.url, - contentType = response.mediaType?.let { - VideoInSavingIntoFile.ContentType( - it.type, - it.subtype - ) - }, - byteStream = response.videoInputStream - ) + VideoInSavingIntoFile( + id = videoInPending.id, + url = videoInPending.url, + contentType = response.mediaType?.let { + VideoInSavingIntoFile.ContentType( + it.type, + it.subtype + ) + }, + byteStream = response.videoInputStream + ) + } catch (throwable: Throwable) { + val exceptionName = (throwable as? HtmlException)?.exceptionName ?: "Unknown Error" + ErrorTracer.addError("video-stream", "$exceptionName error while service.getVideo", throwable = throwable) + throw throwable + } } } @@ -76,5 +85,7 @@ class TikTokDownloadRemoteSource( cause = throwable, html = (throwable as? HtmlException)?.html.orEmpty() ) + } finally { + ErrorTracer.commitErrorTransaction() } } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt index 735f0e8..bd6dfd2 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/CaptchaRequiredException.kt @@ -3,4 +3,7 @@ package org.fnives.tiktokdownloader.data.network.exceptions class CaptchaRequiredException( message: String? = null, cause: Throwable? = null, override val html: String, -) : Throwable(message, cause), HtmlException \ No newline at end of file +) : Throwable(message, cause), HtmlException { + + override val exceptionName: String get() = "CaptchaRequired" +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/HtmlException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/HtmlException.kt index 8e9546a..3d72896 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/HtmlException.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/HtmlException.kt @@ -2,4 +2,5 @@ package org.fnives.tiktokdownloader.data.network.exceptions interface HtmlException { val html: String + val exceptionName: String } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt index 2298c76..4134107 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/NetworkException.kt @@ -3,4 +3,6 @@ package org.fnives.tiktokdownloader.data.network.exceptions class NetworkException( message: String? = null, cause: Throwable? = null, override val html: String, -) : Throwable(message, cause), HtmlException \ No newline at end of file +) : Throwable(message, cause), HtmlException { + override val exceptionName: String get() = "Network" +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt index 94166be..785f875 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/ParsingException.kt @@ -5,4 +5,6 @@ import java.io.IOException class ParsingException( message: String? = null, cause: Throwable? = null, override val html: String, -) : IOException(message, cause), HtmlException \ No newline at end of file +) : IOException(message, cause), HtmlException { + override val exceptionName: String get() = "Parsing" +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoDeletedException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoDeletedException.kt index ad510c8..953bef9 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoDeletedException.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoDeletedException.kt @@ -1,4 +1,6 @@ package org.fnives.tiktokdownloader.data.network.exceptions class VideoDeletedException(override val html: String) : Throwable(), - HtmlException \ No newline at end of file + HtmlException { + override val exceptionName: String get() = "Video Deleted" + } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoPrivateException.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoPrivateException.kt index 073c5ef..d190bca 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoPrivateException.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/exceptions/VideoPrivateException.kt @@ -1,4 +1,6 @@ package org.fnives.tiktokdownloader.data.network.exceptions class VideoPrivateException(override val html: String) : Throwable(), - HtmlException \ No newline at end of file + HtmlException { + override val exceptionName: String get() = "Video Private" + } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt index 7d4afb3..6761158 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/ActualVideoPageUrlConverter.kt @@ -1,7 +1,9 @@ package org.fnives.tiktokdownloader.data.network.parsing.converter import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.errortracking.ErrorTracer import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.HtmlException import org.fnives.tiktokdownloader.data.network.exceptions.VideoDeletedException import org.fnives.tiktokdownloader.data.network.exceptions.VideoPrivateException import org.fnives.tiktokdownloader.data.network.parsing.response.ActualVideoPageUrl @@ -28,7 +30,13 @@ class ActualVideoPageUrlConverter( .split("\"")[0] ActualVideoPageUrl(actualVideoPageUrl, responseBodyAsString) - } catch (_: Throwable) { + } catch (throwable: Throwable) { + val exceptionName = (throwable as? HtmlException)?.exceptionName ?: "Unknown Error" + ErrorTracer.addError( + html = responseBodyAsString, + message = "$exceptionName in ActualVideoPageUrlConverter", + throwable = throwable + ) ActualVideoPageUrl(null, responseBodyAsString) } diff --git a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt index 88858b7..6538f2b 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/data/network/parsing/converter/VideoFileUrlConverter.kt @@ -1,8 +1,10 @@ package org.fnives.tiktokdownloader.data.network.parsing.converter import okhttp3.ResponseBody +import org.fnives.tiktokdownloader.errortracking.ErrorTracer import org.fnives.tiktokdownloader.Logger import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException +import org.fnives.tiktokdownloader.data.network.exceptions.HtmlException import org.fnives.tiktokdownloader.data.network.exceptions.ParsingException import org.fnives.tiktokdownloader.data.network.exceptions.VideoDeletedException import org.fnives.tiktokdownloader.data.network.exceptions.VideoPrivateException @@ -36,15 +38,25 @@ class VideoFileUrlConverter( VideoPrivateException::class, ) private fun convert(responseBody: String): VideoFileUrl { - val html = responseBody.also(throwIfIsCaptchaResponse::invoke) - .also(throwIfVideoIsDeletedResponse::invoke) - .also(throwIfVideoIsPrivateResponse::invoke) - val url = - tryToParseDownloadLink(html).also { Logger.logMessage("parsed download link = $it") } - ?: tryToParseVideoSrc(html).also { Logger.logMessage("parsed video src = $it") } - ?: throw IllegalArgumentException("Couldn't parse url from HTML: $html") + try { + val html = responseBody.also(throwIfIsCaptchaResponse::invoke) + .also(throwIfVideoIsDeletedResponse::invoke) + .also(throwIfVideoIsPrivateResponse::invoke) + val url = + tryToParseDownloadLink(html).also { Logger.logMessage("parsed download link = $it") } + ?: tryToParseVideoSrc(html).also { Logger.logMessage("parsed video src = $it") } + ?: throw IllegalArgumentException("Couldn't parse url from HTML: $html") - return VideoFileUrl(url) + return VideoFileUrl(url) + } catch (throwable: Throwable) { + val exceptionName = (throwable as? HtmlException)?.exceptionName ?: "Unknown Error" + ErrorTracer.addError( + html = responseBody, + message = "$exceptionName in VideoFileUrlConverter", + throwable = throwable + ) + throw throwable + } } companion object { diff --git a/app/src/main/java/org/fnives/tiktokdownloader/errortracking/ErrorTracer.kt b/app/src/main/java/org/fnives/tiktokdownloader/errortracking/ErrorTracer.kt new file mode 100644 index 0000000..b8213e0 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/errortracking/ErrorTracer.kt @@ -0,0 +1,99 @@ +package org.fnives.tiktokdownloader.errortracking + +import android.os.Handler +import android.os.Looper +import androidx.annotation.WorkerThread +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName + + +object ErrorTracer { + + private var errorTransaction: ErrorTransaction? = null + private val errorTransactions = mutableListOf() + private val errorListener = mutableListOf<() -> Unit>() + + val hasErrors get() = errorTransactions.isNotEmpty() + + fun startErrorTransaction(url: String) { + errorTransaction = ErrorTransaction(url) + } + + fun addError(html: String, message: String, throwable: Throwable?) { + errorTransaction?.addError(html = html, message = message, throwable = throwable) + } + + fun cancelErrorTransaction() { + errorTransaction = null + } + + fun commitErrorTransaction() { + val needsToNotify = !hasErrors + val errorTransaction = errorTransaction + if (errorTransaction != null) { + errorTransactions.add(errorTransaction) + } + if (needsToNotify) { + val errorListener = errorListener + errorListener.forEach { + doOnMainThread { + it.invoke() + } + } + } + } + + fun subscribeToHasErrorChanges(listener: () -> Unit): () -> Unit { + doOnMainThread { + errorListener.add(listener) + } + return fun() { + doOnMainThread { + errorListener.remove(listener) + } + } + } + + private fun doOnMainThread(action: () -> Unit) { + Handler(Looper.getMainLooper()).post { action() } + } + + @WorkerThread + fun getErrorAsJSON(): String { + val errorTransactions = errorTransactions + val gson = Gson() + val arrayInner = errorTransactions.joinToString(",", transform = gson::toJson) + return "[$arrayInner]" + } +} + +private class ErrorTransaction( + @SerializedName("url") val url: String +) { + @SerializedName("errors") + val errors = mutableListOf() + + @SerializedName("type") + val errorType = "ErrorTransaction" + + fun addError(html: String, message: String, throwable: Throwable?) { + val stacktrace = throwable?.stackTraceToString() + + errors.add( + ErrorsDuringTransaction( + html = html, + message = message, + stacktrace = stacktrace, + ) + ) + } +} + +private class ErrorsDuringTransaction( + @SerializedName("html") + val html: String, + @SerializedName("message") + val message: String, + @SerializedName("stacktrace") + val stacktrace: String? +) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/errortracking/SendErrorsAsEmail.kt b/app/src/main/java/org/fnives/tiktokdownloader/errortracking/SendErrorsAsEmail.kt new file mode 100644 index 0000000..c5aa187 --- /dev/null +++ b/app/src/main/java/org/fnives/tiktokdownloader/errortracking/SendErrorsAsEmail.kt @@ -0,0 +1,38 @@ +package org.fnives.tiktokdownloader.errortracking + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import java.io.File + +object SendErrorsAsEmail { + + fun send(context: Context) { + val subfolder = File(context.cacheDir, "errors") + if (!subfolder.exists()) { + subfolder.mkdirs() + } + + // Create the file inside subfolder + val tempFile = File(subfolder, "errors.json") + + // Write content + tempFile.writeText(ErrorTracer.getErrorAsJSON()) + val contentUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + tempFile + ) + + val emailIntent = Intent(Intent.ACTION_SEND) + emailIntent.setType("application/json") + emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf("projectsupport202@proton.me")) + emailIntent.putExtra(Intent.EXTRA_SUBJECT, "Reporting Errors from TikTokDownloader") + emailIntent.putExtra(Intent.EXTRA_TEXT, "Attached error as JSON") + emailIntent.putExtra(Intent.EXTRA_STREAM, contentUri) + emailIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + context.startActivity(Intent.createChooser(emailIntent, "Report errors in email...")) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/settings/SettingsFragment.kt b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/settings/SettingsFragment.kt index e940cd1..9d5c4cf 100644 --- a/app/src/main/java/org/fnives/tiktokdownloader/ui/main/settings/SettingsFragment.kt +++ b/app/src/main/java/org/fnives/tiktokdownloader/ui/main/settings/SettingsFragment.kt @@ -3,9 +3,15 @@ package org.fnives.tiktokdownloader.ui.main.settings import android.os.Bundle import android.view.View import androidx.appcompat.widget.SwitchCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import org.fnives.tiktokdownloader.errortracking.ErrorTracer import org.fnives.tiktokdownloader.R import org.fnives.tiktokdownloader.di.provideViewModels +import org.fnives.tiktokdownloader.errortracking.SendErrorsAsEmail class SettingsFragment : Fragment(R.layout.fragment_settings) { @@ -24,6 +30,38 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { alwaysOpenAppHolder.setOnClickListener { viewModel.setAlwaysOpenApp(!alwaysOpenAppSwitch.isChecked) } + + viewLifecycleOwner.lifecycle.addObserver(ErrorObserver()) + view.findViewById(R.id.report_error_cta).setOnClickListener { + SendErrorsAsEmail.send(it.context) + } + } + + + inner class ErrorObserver : LifecycleEventObserver { + private var subscription: (() -> Unit)? = null + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_START -> { + val errorCTA = view?.findViewById(R.id.report_error_cta) + subscription = ErrorTracer.subscribeToHasErrorChanges { + errorCTA?.isVisible = ErrorTracer.hasErrors + } + errorCTA?.isVisible = ErrorTracer.hasErrors + } + + Lifecycle.Event.ON_STOP -> { + subscription?.invoke() + } + + Lifecycle.Event.ON_DESTROY -> { + source.lifecycle.removeObserver(this) + } + + else -> Unit + } + } } companion object { diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index e5ca918..e40b2e6 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -20,26 +20,43 @@ app:cardElevation="@dimen/card_elevation"> + android:orientation="vertical"> + + + + + + + - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 89047e7..95c3ac0 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,6 +1,7 @@ 24dp + 48dp 16dp 8dp 2dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85dfafb..6d12b48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,4 +51,5 @@ Couldn\'t open! Settings Always open app when sharing video + Report errors via e-mail \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..45d3e92 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file