Add option to report errors via e-mail

This commit is contained in:
Gergely Hegedus 2025-05-19 23:12:43 +03:00
parent 9a21cd3ad7
commit 158c322cda
19 changed files with 308 additions and 40 deletions

View file

@ -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.

View file

@ -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"

View file

@ -44,6 +44,16 @@
<service
android:name=".ui.service.QueueService"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -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()
}
}

View file

@ -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
) : Throwable(message, cause), HtmlException {
override val exceptionName: String get() = "CaptchaRequired"
}

View file

@ -2,4 +2,5 @@ package org.fnives.tiktokdownloader.data.network.exceptions
interface HtmlException {
val html: String
val exceptionName: String
}

View file

@ -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
) : Throwable(message, cause), HtmlException {
override val exceptionName: String get() = "Network"
}

View file

@ -5,4 +5,6 @@ import java.io.IOException
class ParsingException(
message: String? = null, cause: Throwable? = null,
override val html: String,
) : IOException(message, cause), HtmlException
) : IOException(message, cause), HtmlException {
override val exceptionName: String get() = "Parsing"
}

View file

@ -1,4 +1,6 @@
package org.fnives.tiktokdownloader.data.network.exceptions
class VideoDeletedException(override val html: String) : Throwable(),
HtmlException
HtmlException {
override val exceptionName: String get() = "Video Deleted"
}

View file

@ -1,4 +1,6 @@
package org.fnives.tiktokdownloader.data.network.exceptions
class VideoPrivateException(override val html: String) : Throwable(),
HtmlException
HtmlException {
override val exceptionName: String get() = "Video Private"
}

View file

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

View file

@ -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 {

View file

@ -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<ErrorTransaction>()
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<ErrorsDuringTransaction>()
@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?
)

View file

@ -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..."))
}
}

View file

@ -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<View>(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<View>(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 {

View file

@ -20,26 +20,43 @@
app:cardElevation="@dimen/card_elevation">
<LinearLayout
android:id="@+id/always_open_app_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:orientation="horizontal"
android:padding="@dimen/default_padding">
android:orientation="vertical">
<LinearLayout
android:id="@+id/always_open_app_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:orientation="horizontal"
android:padding="@dimen/default_padding">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:text="@string/user_preference_always_open_app"
android:textAppearance="?attr/textAppearanceSubtitle1" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/always_open_app"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical" />
</LinearLayout>
<TextView
android:layout_width="0dp"
android:id="@+id/report_error_cta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center_vertical"
android:text="@string/user_preference_always_open_app"
android:background="?attr/selectableItemBackground"
android:minHeight="@dimen/minimum_touch_target"
android:padding="@dimen/default_padding"
android:text="@string/report_errors"
android:textAppearance="?attr/textAppearanceSubtitle1" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/always_open_app"
android:layout_width="wrap_content"
android:layout_gravity="center_vertical"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="activity_horizontal_margin">24dp</dimen>
<dimen name="minimum_touch_target">48dp</dimen>
<dimen name="default_padding">16dp</dimen>
<dimen name="medium_padding">8dp</dimen>
<dimen name="card_elevation">2dp</dimen>

View file

@ -51,4 +51,5 @@
<string name="could_not_open">Couldn\'t open!</string>
<string name="settings">Settings</string>
<string name="user_preference_always_open_app">Always open app when sharing video</string>
<string name="report_errors">Report errors via e-mail</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="errors"
path="errors/" />
</paths>