diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e4f7522116..b295a895c0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -80,6 +80,10 @@ + + - - - + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.navigation3.runtime.NavKey +import dagger.hilt.android.AndroidEntryPoint +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.navigation.NavDisplay +import org.schabi.newpipe.navigation.Screen +import org.schabi.newpipe.ui.theme.AppTheme + +/** + * Single host activity for all Compose-based screens. + * Other parts of the app (including legacy View-based code) launch this activity + * via Intent with extras specifying which screen to display. + */ +@AndroidEntryPoint +class ComposeActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge( + navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + super.onCreate(savedInstanceState) + + val startDestination: NavKey = resolveStartDestination(intent) + + setContent { + AppTheme { + NavDisplay(startDestination) + } + } + } + + private fun resolveStartDestination(intent: Intent): NavKey { + return when (intent.getStringExtra(EXTRA_SCREEN)) { + SCREEN_ERROR -> Screen.Error + + SCREEN_SETTINGS -> Screen.Settings.Home + + else -> throw IllegalArgumentException( + "Unknown screen: ${intent.getStringExtra(EXTRA_SCREEN)}" + ) + } + } + + companion object { + const val EXTRA_SCREEN = "extra_screen" + const val EXTRA_ERROR_INFO = "extra_error_info" + + const val SCREEN_ERROR = "error" + const val SCREEN_SETTINGS = "settings" + + fun errorIntent(context: Context, errorInfo: ErrorInfo): Intent { + return Intent(context, ComposeActivity::class.java).apply { + putExtra(EXTRA_SCREEN, SCREEN_ERROR) + putExtra(EXTRA_ERROR_INFO, errorInfo) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + fun settingsIntent(context: Context): Intent { + return Intent(context, ComposeActivity::class.java).apply { + putExtra(EXTRA_SCREEN, SCREEN_SETTINGS) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt deleted file mode 100644 index c68a2cfd1e1..00000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt +++ /dev/null @@ -1,282 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2015-2026 NewPipe contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.error - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.IntentCompat -import androidx.core.net.toUri -import com.grack.nanojson.JsonWriter -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.ActivityErrorBinding -import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.ThemeHelper -import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.util.text.setTextWithLinks - -/** - * This activity is used to show error details and allow reporting them in various ways. - * Use [ErrorUtil.openActivity] to correctly open this activity. - */ -class ErrorActivity : AppCompatActivity() { - private lateinit var errorInfo: ErrorInfo - private lateinit var currentTimeStamp: String - - private lateinit var binding: ActivityErrorBinding - - private val contentCountryString: String - get() = Localization.getPreferredContentCountry(this).countryCode - - private val contentLanguageString: String - get() = Localization.getPreferredLocalization(this).localizationCode - - private val appLanguage: String - get() = Localization.getAppLocale().toString() - - private val osString: String - get() { - val name = System.getProperty("os.name")!! - val osBase = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Build.VERSION.BASE_OS.ifEmpty { "Android" } - } else { - "Android" - } - return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}" - } - - private val errorEmailSubject: String - get() = "$ERROR_EMAIL_SUBJECT ${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}" - - // ///////////////////////////////////////////////////////////////////// - // Activity lifecycle - // ///////////////////////////////////////////////////////////////////// - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - ThemeHelper.setDayNightMode(this) - ThemeHelper.setTheme(this) - - binding = ActivityErrorBinding.inflate(layoutInflater) - setContentView(binding.getRoot()) - - setSupportActionBar(binding.toolbarLayout.toolbar) - supportActionBar?.apply { - setDisplayHomeAsUpEnabled(true) - setTitle(R.string.error_report_title) - setDisplayShowTitleEnabled(true) - } - - errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java)!! - - // important add guru meditation - addGuruMeditation() - // print current time, as zoned ISO8601 timestamp - currentTimeStamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) - - binding.errorReportEmailButton.setOnClickListener { _ -> - openPrivacyPolicyDialog(this, "EMAIL") - } - - binding.errorReportCopyButton.setOnClickListener { _ -> - ShareUtils.copyToClipboard(this, buildMarkdown()) - } - - binding.errorReportGitHubButton.setOnClickListener { _ -> - openPrivacyPolicyDialog(this, "GITHUB") - } - - // normal bugreport - buildInfo(errorInfo) - binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this)) - binding.errorView.text = formErrorText(errorInfo.stackTraces) - - // print stack trace once again for debugging: - errorInfo.stackTraces.forEach { Log.e(TAG, it) } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.error_menu, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressed() - true - } - - R.id.menu_item_share_error -> { - ShareUtils.shareText( - applicationContext, - getString(R.string.error_report_title), - buildJson() - ) - true - } - - else -> false - } - } - - private fun openPrivacyPolicyDialog(context: Context, action: String) { - AlertDialog.Builder(context) - .setIcon(android.R.drawable.ic_dialog_alert) - .setTitle(R.string.privacy_policy_title) - .setMessage(R.string.start_accept_privacy_policy) - .setCancelable(false) - .setNeutralButton(R.string.read_privacy_policy) { _, _ -> - ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url)) - } - .setPositiveButton(R.string.accept) { _, _ -> - if (action == "EMAIL") { // send on email - val intent = Intent(Intent.ACTION_SENDTO) - .setData("mailto:".toUri()) // only email apps should handle this - .putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS)) - .putExtra(Intent.EXTRA_SUBJECT, errorEmailSubject) - .putExtra(Intent.EXTRA_TEXT, buildJson()) - ShareUtils.openIntentInApp(context, intent) - } else if (action == "GITHUB") { // open the NewPipe issue page on GitHub - ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL) - } - } - .setNegativeButton(R.string.decline, null) - .show() - } - - private fun formErrorText(stacktrace: Array): String { - val separator = "-------------------------------------" - return stacktrace.joinToString(separator + "\n", separator + "\n", separator) - } - - private fun buildInfo(info: ErrorInfo) { - binding.errorInfoLabelsView.text = getString(R.string.info_labels) - - val text = info.userAction.message + "\n" + - info.request + "\n" + - contentLanguageString + "\n" + - contentCountryString + "\n" + - appLanguage + "\n" + - info.getServiceName() + "\n" + - currentTimeStamp + "\n" + - packageName + "\n" + - BuildConfig.VERSION_NAME + "\n" + - osString - - binding.errorInfosView.text = text - } - - private fun buildJson(): String { - try { - return JsonWriter.string() - .`object`() - .value("user_action", errorInfo.userAction.message) - .value("request", errorInfo.request) - .value("content_language", contentLanguageString) - .value("content_country", contentCountryString) - .value("app_language", appLanguage) - .value("service", errorInfo.getServiceName()) - .value("package", packageName) - .value("version", BuildConfig.VERSION_NAME) - .value("os", osString) - .value("time", currentTimeStamp) - .array("exceptions", errorInfo.stackTraces.toList()) - .value("user_comment", binding.errorCommentBox.getText().toString()) - .end() - .done() - } catch (exception: Exception) { - Log.e(TAG, "Error while erroring: Could not build json", exception) - } - - return "" - } - - private fun buildMarkdown(): String { - try { - return buildString(1024) { - val userComment = binding.errorCommentBox.text.toString() - if (userComment.isNotEmpty()) { - appendLine(userComment) - } - - // basic error info - appendLine("## Exception") - appendLine("* __User Action:__ ${errorInfo.userAction.message}") - appendLine("* __Request:__ ${errorInfo.request}") - appendLine("* __Content Country:__ $contentCountryString") - appendLine("* __Content Language:__ $contentLanguageString") - appendLine("* __App Language:__ $appLanguage") - appendLine("* __Service:__ ${errorInfo.getServiceName()}") - appendLine("* __Timestamp:__ $currentTimeStamp") - appendLine("* __Package:__ $packageName") - appendLine("* __Service:__ ${errorInfo.getServiceName()}") - appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}") - appendLine("* __OS:__ $osString") - - // Collapse all logs to a single paragraph when there are more than one - // to keep the GitHub issue clean. - if (errorInfo.stackTraces.size > 1) { - append("
Exceptions (") - append(errorInfo.stackTraces.size) - append(")

\n") - } - - // add the logs - errorInfo.stackTraces.forEachIndexed { index, stacktrace -> - append("

Crash log ") - if (errorInfo.stackTraces.size > 1) { - append(index + 1) - } - append("") - append("

\n") - append("\n```\n${stacktrace}\n```\n") - append("

\n") - } - - // make sure to close everything - if (errorInfo.stackTraces.size > 1) { - append("

\n") - } - - append("
\n") - } - } catch (exception: Exception) { - Log.e(TAG, "Error while erroring: Could not build markdown", exception) - return "" - } - } - - private fun addGuruMeditation() { - // just an easter egg - var text = binding.errorSorryView.text.toString() - text += "\n" + getString(R.string.guru_meditation) - binding.errorSorryView.text = text - } - - companion object { - // LOG TAGS - private val TAG = ErrorActivity::class.java.toString() - - // BUNDLE TAGS - const val ERROR_INFO = "error_info" - - private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org" - private const val ERROR_EMAIL_SUBJECT = "Exception in " - - private const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues" - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorReportHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorReportHelper.kt new file mode 100644 index 00000000000..03958b97137 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorReportHelper.kt @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2015-2026 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.error + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.net.toUri +import com.grack.nanojson.JsonWriter +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.R +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.external_communication.ShareUtils + +/** + * Pure utility functions for building and sending error reports. + * No Activity dependency — only requires a [Context]. + */ +object ErrorReportHelper { + + private val TAG = ErrorReportHelper::class.java.simpleName + + private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org" + private const val ERROR_EMAIL_SUBJECT = "Exception in " + const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues" + + fun buildJson(context: Context, errorInfo: ErrorInfo, comment: String): String { + try { + return JsonWriter.string() + .`object`() + .value("user_action", errorInfo.userAction.message) + .value("request", errorInfo.request) + .value("content_language", getContentLanguage(context)) + .value("content_country", getContentCountry(context)) + .value("app_language", getAppLanguage()) + .value("service", errorInfo.getServiceName()) + .value("package", context.packageName) + .value("version", BuildConfig.VERSION_NAME) + .value("os", getOsString()) + .value("time", getCurrentTimestamp()) + .array("exceptions", errorInfo.stackTraces.toList()) + .value("user_comment", comment) + .end() + .done() + } catch (exception: Exception) { + Log.e(TAG, "Could not build json", exception) + } + return "" + } + + fun buildMarkdown(context: Context, errorInfo: ErrorInfo, comment: String): String { + try { + return buildString(1024) { + if (comment.isNotEmpty()) { + appendLine(comment) + } + + appendLine("## Exception") + appendLine("* __User Action:__ ${errorInfo.userAction.message}") + appendLine("* __Request:__ ${errorInfo.request}") + appendLine("* __Content Country:__ ${getContentCountry(context)}") + appendLine("* __Content Language:__ ${getContentLanguage(context)}") + appendLine("* __App Language:__ ${getAppLanguage()}") + appendLine("* __Service:__ ${errorInfo.getServiceName()}") + appendLine("* __Timestamp:__ ${getCurrentTimestamp()}") + appendLine("* __Package:__ ${context.packageName}") + appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}") + appendLine("* __OS:__ ${getOsString()}") + + if (errorInfo.stackTraces.size > 1) { + append("
Exceptions (") + append(errorInfo.stackTraces.size) + append(")

\n") + } + + errorInfo.stackTraces.forEachIndexed { index, stacktrace -> + append("

Crash log ") + if (errorInfo.stackTraces.size > 1) { + append(index + 1) + } + append("

\n") + append("\n```\n$stacktrace\n```\n") + append("

\n") + } + + if (errorInfo.stackTraces.size > 1) { + append("

\n") + } + + append("
\n") + } + } catch (exception: Exception) { + Log.e(TAG, "Could not build markdown", exception) + return "" + } + } + + fun sendErrorEmail(context: Context, errorInfo: ErrorInfo, comment: String) { + val subject = "$ERROR_EMAIL_SUBJECT${context.getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}" + val intent = Intent(Intent.ACTION_SENDTO) + .setData("mailto:".toUri()) + .putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS)) + .putExtra(Intent.EXTRA_SUBJECT, subject) + .putExtra(Intent.EXTRA_TEXT, buildJson(context, errorInfo, comment)) + ShareUtils.openIntentInApp(context, intent) + } + + fun shareError(context: Context, errorInfo: ErrorInfo, comment: String) { + ShareUtils.shareText( + context, + context.getString(R.string.error_report_title), + buildJson(context, errorInfo, comment) + ) + } + + fun copyForGitHub(context: Context, errorInfo: ErrorInfo, comment: String) { + ShareUtils.copyToClipboard(context, buildMarkdown(context, errorInfo, comment)) + } + + fun openGitHubIssues(context: Context) { + ShareUtils.openUrlInApp(context, ERROR_GITHUB_ISSUE_URL) + } + + fun openPrivacyPolicy(context: Context) { + ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url)) + } + + private fun getContentLanguage(context: Context): String = Localization.getPreferredLocalization(context).localizationCode + + private fun getContentCountry(context: Context): String = Localization.getPreferredContentCountry(context).countryCode + + private fun getAppLanguage(): String = Localization.getAppLocale().toString() + + private fun getOsString(): String { + val name = System.getProperty("os.name")!! + val osBase = Build.VERSION.BASE_OS.ifEmpty { "Android" } + return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}" + } + + private fun getCurrentTimestamp(): String = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) +} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt index 0fa302623b5..b86cc4c3dd3 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.google.android.material.snackbar.Snackbar +import org.schabi.newpipe.ComposeActivity import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R @@ -25,7 +26,7 @@ import org.schabi.newpipe.R * is available. * - Use a notification if the exception happens inside a background service (player, subscription * import, ...) or there is no activity/fragment from which to extract a root view. - * - Finally use the error activity only as a last resort in case the exception is critical and + * - Finally use the error screen only as a last resort in case the exception is critical and * happens in an open activity (since the workflow would be interrupted anyway in that case). */ class ErrorUtil { @@ -33,7 +34,7 @@ class ErrorUtil { private const val ERROR_REPORT_NOTIFICATION_ID = 5340681 /** - * Starts a new error activity allowing the user to report the provided error. Only use this + * Starts a new error screen allowing the user to report the provided error. Only use this * method directly as a last resort in case the exception is critical and happens in an open * activity (since the workflow would be interrupted anyway in that case). So never use this * for background services. @@ -50,12 +51,12 @@ class ErrorUtil { ) { createNotification(context, errorInfo) } else { - context.startActivity(getErrorActivityIntent(context, errorInfo)) + context.startActivity(getErrorScreenIntent(context, errorInfo)) } } /** - * Show a bottom snackbar to the user, with a report button that opens the error activity. + * Show a bottom snackbar to the user, with a report button that opens the error screen. * Use this method if the exception is not critical and it happens in a place where a root * view is available. * @@ -70,7 +71,7 @@ class ErrorUtil { } /** - * Show a bottom snackbar to the user, with a report button that opens the error activity. + * Show a bottom snackbar to the user, with a report button that opens the error screen. * Use this method if the exception is not critical and it happens in a place where a root * view is available. * @@ -104,7 +105,7 @@ class ErrorUtil { } /** - * Create an error notification. Tapping on the notification opens the error activity. Use + * Create an error notification. Tapping on the notification opens the error screen. Use * this method if the exception happens inside a background service (player, subscription * import, ...) or there is no activity/fragment from which to extract a root view. * @@ -128,7 +129,7 @@ class ErrorUtil { PendingIntentCompat.getActivity( context, 0, - getErrorActivityIntent(context, errorInfo), + getErrorScreenIntent(context, errorInfo), PendingIntent.FLAG_UPDATE_CURRENT, false ) @@ -147,11 +148,8 @@ class ErrorUtil { } } - private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent { - val intent = Intent(context, ErrorActivity::class.java) - intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return intent + private fun getErrorScreenIntent(context: Context, errorInfo: ErrorInfo): Intent { + return ComposeActivity.errorIntent(context, errorInfo) } private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) { @@ -162,7 +160,7 @@ class ErrorUtil { Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG) .setActionTextColor(Color.YELLOW) .setAction(context.getString(R.string.error_snackbar_action).uppercase()) { - context.startActivity(getErrorActivityIntent(context, errorInfo)) + context.startActivity(getErrorScreenIntent(context, errorInfo)) }.show() } } diff --git a/app/src/main/java/org/schabi/newpipe/navigation/NavDisplay.kt b/app/src/main/java/org/schabi/newpipe/navigation/NavDisplay.kt new file mode 100644 index 00000000000..94d5cdf6161 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/navigation/NavDisplay.kt @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.navigation + +import androidx.activity.compose.LocalActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.IntentCompat +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import org.schabi.newpipe.ComposeActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorReportHelper +import org.schabi.newpipe.ui.screens.ErrorReportEvent +import org.schabi.newpipe.ui.screens.ErrorReportScreen +import org.schabi.newpipe.ui.screens.settings.debug.DebugScreen +import org.schabi.newpipe.ui.screens.settings.home.SettingsHomeScreen + +/** + * Top-level navigation display for all Compose screens in the app. + * @param startDestination the initial screen to display, resolved from the launching Intent. + */ +@Composable +fun NavDisplay(startDestination: NavKey) { + val backstack = rememberNavBackStack(startDestination) + val context = LocalContext.current + + // TODO: Drop this logic once everything is in Compose + val activity = LocalActivity.current + + fun onNavigateUp() { + if (backstack.size > 1) { + backstack.removeLastOrNull() + } else { + activity?.finish() + } + } + + NavDisplay( + backStack = backstack, + onBack = ::onNavigateUp, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + // Error Report + entry { + val errorInfo = remember { + IntentCompat.getParcelableExtra( + activity!!.intent, + ComposeActivity.EXTRA_ERROR_INFO, + ErrorInfo::class.java + )!! + } + + ErrorReportScreen( + errorInfo = errorInfo, + onEvent = { event -> + when (event) { + is ErrorReportEvent.ReportViaEmail -> + ErrorReportHelper.sendErrorEmail(context, errorInfo, event.comment) + + is ErrorReportEvent.CopyForGitHub -> + ErrorReportHelper.copyForGitHub(context, errorInfo, event.comment) + + is ErrorReportEvent.ReportOnGitHub -> + ErrorReportHelper.openGitHubIssues(context) + + is ErrorReportEvent.ReadPrivacyPolicy -> + ErrorReportHelper.openPrivacyPolicy(context) + + is ErrorReportEvent.ShareError -> + ErrorReportHelper.shareError(context, errorInfo, event.comment) + + is ErrorReportEvent.NavigateUp -> + onNavigateUp() + } + } + ) + } + + // Settings + entry { + SettingsHomeScreen( + onNavigate = { screen -> backstack.add(screen) }, + onBackClick = ::onNavigateUp + ) + } + + entry { + Text(stringResource(id = R.string.settings_category_player_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_player_behavior_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_downloads_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_look_and_feel_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_history_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_content_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_feed_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_services_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_language_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_backup_restore_title)) + } + + entry { + Text(stringResource(id = R.string.settings_category_updates_title)) + } + + entry { + DebugScreen(onBackClick = { backstack.removeLastOrNull() }) + } + } + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/navigation/Screen.kt b/app/src/main/java/org/schabi/newpipe/navigation/Screen.kt index b791fb80495..c2fe138fc87 100644 --- a/app/src/main/java/org/schabi/newpipe/navigation/Screen.kt +++ b/app/src/main/java/org/schabi/newpipe/navigation/Screen.kt @@ -11,6 +11,9 @@ import kotlinx.serialization.Serializable @Serializable sealed interface Screen : NavKey { + @Serializable + data object Error : Screen + sealed interface Settings : Screen { @Serializable data object Home : Settings diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsV2Activity.kt b/app/src/main/java/org/schabi/newpipe/settings/SettingsV2Activity.kt deleted file mode 100644 index bcbc43ef3a3..00000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsV2Activity.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors - * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.settings - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import dagger.hilt.android.AndroidEntryPoint -import org.schabi.newpipe.ui.screens.settings.navigation.SettingsNavigation -import org.schabi.newpipe.ui.theme.AppTheme - -@AndroidEntryPoint -class SettingsV2Activity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - AppTheme { - SettingsNavigation( - onExitSettings = { finish() } - ) - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/PrivacyPolicyDialog.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/PrivacyPolicyDialog.kt new file mode 100644 index 00000000000..7124dfa7f1d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/PrivacyPolicyDialog.kt @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.ui.components.common + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.theme.AppTheme + +@Composable +fun PrivacyPolicyDialog( + onAccept: () -> Unit, + onDecline: () -> Unit, + onReadPrivacyPolicy: () -> Unit +) { + AlertDialog( + onDismissRequest = onDecline, + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text( + text = stringResource(R.string.privacy_policy_title) + ) + }, + text = { + Column { + Text( + text = stringResource(R.string.start_accept_privacy_policy) + ) + TextButton(onClick = onReadPrivacyPolicy) { + Text( + text = stringResource(R.string.read_privacy_policy), + color = MaterialTheme.colorScheme.secondary + ) + } + } + }, + confirmButton = { + TextButton(onClick = onAccept) { + Text( + text = stringResource(R.string.accept) + ) + } + }, + dismissButton = { + TextButton(onClick = onDecline) { + Text( + text = stringResource(R.string.decline), + color = MaterialTheme.colorScheme.error + ) + } + } + ) +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun PrivacyPolicyDialogPreview() { + AppTheme { + PrivacyPolicyDialog( + onAccept = {}, + onDecline = {}, + onReadPrivacyPolicy = {} + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/screens/ErrorReportScreen.kt b/app/src/main/java/org/schabi/newpipe/ui/screens/ErrorReportScreen.kt new file mode 100644 index 00000000000..d2920905d75 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/screens/ErrorReportScreen.kt @@ -0,0 +1,261 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.ui.screens + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.ui.components.common.PrivacyPolicyDialog +import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization + +private const val ACTION_EMAIL = "EMAIL" +private const val ACTION_GITHUB = "GITHUB" + +sealed interface ErrorReportEvent { + data class ReportViaEmail(val comment: String) : ErrorReportEvent + data class CopyForGitHub(val comment: String) : ErrorReportEvent + data object ReportOnGitHub : ErrorReportEvent + data object ReadPrivacyPolicy : ErrorReportEvent + data class ShareError(val comment: String) : ErrorReportEvent + data object NavigateUp : ErrorReportEvent +} + +@Composable +fun ErrorReportScreen( + errorInfo: ErrorInfo, + onEvent: (ErrorReportEvent) -> Unit +) { + val context = LocalContext.current + + ErrorReportContent( + errorMessage = errorInfo.getMessage(context).toString(), + infoLabels = stringResource(R.string.info_labels), + infoValues = buildInfoString(context, errorInfo), + errorDetails = formErrorText(errorInfo.stackTraces), + onEvent = onEvent + ) +} + +@Composable +private fun ErrorReportContent( + errorMessage: String, + infoLabels: String, + infoValues: String, + errorDetails: String, + onEvent: (ErrorReportEvent) -> Unit +) { + var comment by rememberSaveable { mutableStateOf("") } + var privacyDialogAction by rememberSaveable { mutableStateOf(null) } + + privacyDialogAction?.let { action -> + PrivacyPolicyDialog( + onAccept = { + privacyDialogAction = null + when (action) { + ACTION_EMAIL -> onEvent(ErrorReportEvent.ReportViaEmail(comment)) + ACTION_GITHUB -> onEvent(ErrorReportEvent.ReportOnGitHub) + } + }, + onDecline = { privacyDialogAction = null }, + onReadPrivacyPolicy = { onEvent(ErrorReportEvent.ReadPrivacyPolicy) } + ) + } + + ScaffoldWithToolbar( + title = stringResource(R.string.error_report_title), + onBackClick = { onEvent(ErrorReportEvent.NavigateUp) }, + actions = { + IconButton(onClick = { onEvent(ErrorReportEvent.ShareError(comment)) }) { + Icon( + painter = painterResource(R.drawable.ic_share), + contentDescription = stringResource(R.string.share) + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Sorry header + Text( + text = stringResource(R.string.sorry_string), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + // What happened + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.what_happened_headline), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.primary + ) + + // Device info + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.what_device_headline), + style = MaterialTheme.typography.titleMedium + ) + Row { + Text( + text = infoLabels, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = infoValues, + modifier = Modifier + .padding(start = 16.dp) + .horizontalScroll(rememberScrollState()) + ) + } + + // Error details + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.error_details_headline), + style = MaterialTheme.typography.titleMedium + ) + SelectionContainer { + Text( + text = errorDetails, + fontFamily = FontFamily.Monospace, + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) + } + + // User comment + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.your_comment), + style = MaterialTheme.typography.titleMedium + ) + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + modifier = Modifier.fillMaxWidth() + ) + + // Report via email button + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { privacyDialogAction = ACTION_EMAIL }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.error_report_button_text)) + } + + // GitHub notice + Text( + text = stringResource(R.string.error_report_open_github_notice), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 10.dp, bottom = 5.dp) + ) + + // Copy for GitHub button + Button( + onClick = { onEvent(ErrorReportEvent.CopyForGitHub(comment)) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.copy_for_github)) + } + + // Report on GitHub button + Button( + onClick = { privacyDialogAction = ACTION_GITHUB }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.error_report_open_issue_button_text)) + } + } + } +} + +private fun buildInfoString(context: Context, errorInfo: ErrorInfo): String { + val contentLanguage = Localization.getPreferredLocalization(context).localizationCode + val contentCountry = Localization.getPreferredContentCountry(context).countryCode + val appLanguage = Localization.getAppLocale().toString() + val osName = System.getProperty("os.name")!! + val osBase = Build.VERSION.BASE_OS.ifEmpty { "Android" } + val osString = "$osName $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}" + val timestamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + + return errorInfo.userAction.message + "\n" + + errorInfo.request + "\n" + + contentLanguage + "\n" + + contentCountry + "\n" + + appLanguage + "\n" + + errorInfo.getServiceName() + "\n" + + timestamp + "\n" + + context.packageName + "\n" + + BuildConfig.VERSION_NAME + "\n" + + osString +} + +private fun formErrorText(stackTraces: Array): String { + val separator = "-------------------------------------" + return stackTraces.joinToString(separator + "\n", separator + "\n", separator) +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun ErrorReportScreenPreview() { + AppTheme { + ErrorReportContent( + errorMessage = "Requested list not handled", + infoLabels = "What:\nRequest:\nContent Language:\nContent Country:\nApp Language:\nService:\nTimestamp:\nPackage:\nVersion:\nOS version:", + infoValues = "Requested list\nnone\nen\nUS\nen_US\nYouTube\n2026-04-17T12:00:00Z\norg.schabi.newpipe\n0.27.5\nAndroid 14 - 34", + errorDetails = "-------------------------------------\njava.lang.IllegalArgumentException: ...\n\tat org.schabi.newpipe.SomeClass.method(SomeClass.kt:42)\n-------------------------------------", + onEvent = {} + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/screens/settings/navigation/SettingsNavigation.kt b/app/src/main/java/org/schabi/newpipe/ui/screens/settings/navigation/SettingsNavigation.kt deleted file mode 100644 index 421d71aea1d..00000000000 --- a/app/src/main/java/org/schabi/newpipe/ui/screens/settings/navigation/SettingsNavigation.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package org.schabi.newpipe.ui.screens.settings.navigation - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay -import org.schabi.newpipe.R -import org.schabi.newpipe.navigation.Screen -import org.schabi.newpipe.ui.screens.settings.debug.DebugScreen -import org.schabi.newpipe.ui.screens.settings.home.SettingsHomeScreen - -@Composable -fun SettingsNavigation(onExitSettings: () -> Unit) { - val backStack = rememberNavBackStack(Screen.Settings.Home) - - val handleBack: () -> Unit = { - if (backStack.size > 1) { - backStack.removeLastOrNull() - } else { - onExitSettings() - } - } - - NavDisplay( - backStack = backStack, - onBack = handleBack, - entryProvider = entryProvider { - entry { - SettingsHomeScreen( - onNavigate = { screen -> backStack.add(screen) }, - onBackClick = handleBack - ) - } - entry { Text(stringResource(id = R.string.settings_category_player_title)) } - entry { Text(stringResource(id = R.string.settings_category_player_behavior_title)) } - entry { Text(stringResource(id = R.string.settings_category_downloads_title)) } - entry { Text(stringResource(id = R.string.settings_category_look_and_feel_title)) } - entry { Text(stringResource(id = R.string.settings_category_history_title)) } - entry { Text(stringResource(id = R.string.settings_category_content_title)) } - entry { Text(stringResource(id = R.string.settings_category_feed_title)) } - entry { Text(stringResource(id = R.string.settings_category_services_title)) } - entry { Text(stringResource(id = R.string.settings_category_language_title)) } - entry { Text(stringResource(id = R.string.settings_category_backup_restore_title)) } - entry { Text(stringResource(id = R.string.settings_category_updates_title)) } - entry { - DebugScreen(onBackClick = { backStack.removeLastOrNull() }) - } - }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ) - ) -} diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 0a7906b8d75..e612e8efd49 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -66,8 +66,8 @@ import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.ComposeActivity; import org.schabi.newpipe.settings.SettingsActivity; -import org.schabi.newpipe.settings.SettingsV2Activity; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; @@ -640,12 +640,13 @@ public static void openAbout(final Context context) { } public static void openSettings(final Context context) { - final Class settingsClass = PreferenceManager.getDefaultSharedPreferences(context) + final boolean useCompose = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(Localization.compatGetString(context, - R.string.settings_layout_redesign_key), false) - ? SettingsV2Activity.class : SettingsActivity.class; + R.string.settings_layout_redesign_key), false); - final Intent intent = new Intent(context, settingsClass); + final Intent intent = useCompose + ? ComposeActivity.Companion.settingsIntent(context) + : new Intent(context, SettingsActivity.class); context.startActivity(intent); }