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 Here is another Secret Video QR code just for the curious minded
![QR Code for Secret](secret_video_qr_code.png) ![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" def glide_version = "4.15.1"
implementation "com.github.bumptech.glide:glide:$glide_version" 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" def okhttp_version = "4.12.0"
implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:retrofit:2.9.0"

View file

@ -44,6 +44,16 @@
<service <service
android:name=".ui.service.QueueService" android:name=".ui.service.QueueService"
android:foregroundServiceType="dataSync" /> 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> </application>
</manifest> </manifest>

View file

@ -3,6 +3,7 @@ package org.fnives.tiktokdownloader.data.network
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fnives.tiktokdownloader.errortracking.ErrorTracer
import org.fnives.tiktokdownloader.Logger import org.fnives.tiktokdownloader.Logger
import org.fnives.tiktokdownloader.data.model.VideoInPending import org.fnives.tiktokdownloader.data.model.VideoInPending
import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile import org.fnives.tiktokdownloader.data.model.VideoInSavingIntoFile
@ -28,6 +29,7 @@ class TikTokDownloadRemoteSource(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
cookieStore.clear() cookieStore.clear()
wrapIntoProperException { wrapIntoProperException {
ErrorTracer.startErrorTransaction(videoInPending.url)
delay(delayBeforeRequest) // added just so captcha trigger may not happen delay(delayBeforeRequest) // added just so captcha trigger may not happen
val actualUrl = service.getContentActualUrlAndCookie(videoInPending.url) val actualUrl = service.getContentActualUrlAndCookie(videoInPending.url)
val videoUrl: VideoFileUrl val videoUrl: VideoFileUrl
@ -43,7 +45,9 @@ class TikTokDownloadRemoteSource(
} }
Logger.logMessage("videoFileUrl found = ${videoUrl.videoFileUrl}") Logger.logMessage("videoFileUrl found = ${videoUrl.videoFileUrl}")
delay(delayBeforeRequest) // added just so captcha trigger may not happen delay(delayBeforeRequest) // added just so captcha trigger may not happen
try {
val response = service.getVideo(videoUrl.videoFileUrl) val response = service.getVideo(videoUrl.videoFileUrl)
ErrorTracer.cancelErrorTransaction()
VideoInSavingIntoFile( VideoInSavingIntoFile(
id = videoInPending.id, id = videoInPending.id,
@ -56,6 +60,11 @@ class TikTokDownloadRemoteSource(
}, },
byteStream = response.videoInputStream 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, cause = throwable,
html = (throwable as? HtmlException)?.html.orEmpty() html = (throwable as? HtmlException)?.html.orEmpty()
) )
} finally {
ErrorTracer.commitErrorTransaction()
} }
} }

View file

@ -3,4 +3,7 @@ package org.fnives.tiktokdownloader.data.network.exceptions
class CaptchaRequiredException( class CaptchaRequiredException(
message: String? = null, cause: Throwable? = null, message: String? = null, cause: Throwable? = null,
override val html: String, 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 { interface HtmlException {
val html: String val html: String
val exceptionName: String
} }

View file

@ -3,4 +3,6 @@ package org.fnives.tiktokdownloader.data.network.exceptions
class NetworkException( class NetworkException(
message: String? = null, cause: Throwable? = null, message: String? = null, cause: Throwable? = null,
override val html: String, 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( class ParsingException(
message: String? = null, cause: Throwable? = null, message: String? = null, cause: Throwable? = null,
override val html: String, 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 package org.fnives.tiktokdownloader.data.network.exceptions
class VideoDeletedException(override val html: String) : Throwable(), 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 package org.fnives.tiktokdownloader.data.network.exceptions
class VideoPrivateException(override val html: String) : Throwable(), 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 package org.fnives.tiktokdownloader.data.network.parsing.converter
import okhttp3.ResponseBody import okhttp3.ResponseBody
import org.fnives.tiktokdownloader.errortracking.ErrorTracer
import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException 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.VideoDeletedException
import org.fnives.tiktokdownloader.data.network.exceptions.VideoPrivateException import org.fnives.tiktokdownloader.data.network.exceptions.VideoPrivateException
import org.fnives.tiktokdownloader.data.network.parsing.response.ActualVideoPageUrl import org.fnives.tiktokdownloader.data.network.parsing.response.ActualVideoPageUrl
@ -28,7 +30,13 @@ class ActualVideoPageUrlConverter(
.split("\"")[0] .split("\"")[0]
ActualVideoPageUrl(actualVideoPageUrl, responseBodyAsString) 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) ActualVideoPageUrl(null, responseBodyAsString)
} }

View file

@ -1,8 +1,10 @@
package org.fnives.tiktokdownloader.data.network.parsing.converter package org.fnives.tiktokdownloader.data.network.parsing.converter
import okhttp3.ResponseBody import okhttp3.ResponseBody
import org.fnives.tiktokdownloader.errortracking.ErrorTracer
import org.fnives.tiktokdownloader.Logger import org.fnives.tiktokdownloader.Logger
import org.fnives.tiktokdownloader.data.network.exceptions.CaptchaRequiredException 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.ParsingException
import org.fnives.tiktokdownloader.data.network.exceptions.VideoDeletedException import org.fnives.tiktokdownloader.data.network.exceptions.VideoDeletedException
import org.fnives.tiktokdownloader.data.network.exceptions.VideoPrivateException import org.fnives.tiktokdownloader.data.network.exceptions.VideoPrivateException
@ -36,6 +38,7 @@ class VideoFileUrlConverter(
VideoPrivateException::class, VideoPrivateException::class,
) )
private fun convert(responseBody: String): VideoFileUrl { private fun convert(responseBody: String): VideoFileUrl {
try {
val html = responseBody.also(throwIfIsCaptchaResponse::invoke) val html = responseBody.also(throwIfIsCaptchaResponse::invoke)
.also(throwIfVideoIsDeletedResponse::invoke) .also(throwIfVideoIsDeletedResponse::invoke)
.also(throwIfVideoIsPrivateResponse::invoke) .also(throwIfVideoIsPrivateResponse::invoke)
@ -45,6 +48,15 @@ class VideoFileUrlConverter(
?: throw IllegalArgumentException("Couldn't parse url from HTML: $html") ?: 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 { 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.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.widget.SwitchCompat import androidx.appcompat.widget.SwitchCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment 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.R
import org.fnives.tiktokdownloader.di.provideViewModels import org.fnives.tiktokdownloader.di.provideViewModels
import org.fnives.tiktokdownloader.errortracking.SendErrorsAsEmail
class SettingsFragment : Fragment(R.layout.fragment_settings) { class SettingsFragment : Fragment(R.layout.fragment_settings) {
@ -24,6 +30,38 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
alwaysOpenAppHolder.setOnClickListener { alwaysOpenAppHolder.setOnClickListener {
viewModel.setAlwaysOpenApp(!alwaysOpenAppSwitch.isChecked) 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 { companion object {

View file

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

View file

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

View file

@ -51,4 +51,5 @@
<string name="could_not_open">Couldn\'t open!</string> <string name="could_not_open">Couldn\'t open!</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="user_preference_always_open_app">Always open app when sharing video</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> </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>