diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 900278f..715bf65 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,7 +17,7 @@ $ ./gradlew spotlessApply Then dump binary API of this library that is public in sense of Kotlin visibilities and ensures that the public binary API wasn't changed in a way that make this change binary incompatible. ```bash -./gradlew apiDump +./gradlew updateKotlinAbi ``` Please correct any failures before requesting a review. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index d21d8e3..0000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Android CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - lint: - name: Spotless check - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v7 - - name: Set up JDK - uses: actions/setup-java@v6 - with: - distribution: zulu - java-version: 17 - - uses: gradle/actions/setup-gradle@v6 - - name: spotless - run: ./gradlew spotlessCheck - - api_check: - name: API check - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v7 - - name: Set up JDK - uses: actions/setup-java@v6 - with: - distribution: zulu - java-version: 17 - - uses: gradle/actions/setup-gradle@v6 - - name: API check - run: ./gradlew apiCheck - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: set up JDK - uses: actions/setup-java@v6 - with: - distribution: zulu - java-version: 17 - - - uses: gradle/actions/setup-gradle@v6 - - name: Make Gradle executable - run: chmod +x ./gradlew - - - name: Build with Gradle - run: | - ./gradlew --scan --stacktrace \ - assemble -x :benchmark:pixel6api31Setup -x :benchmark:pixel6api31NonMinifiedReleaseAndroidTest -x :benchmark:collectNonMinifiedReleaseBaselineProfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e9fab3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + spotless: + name: Spotless check + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up JDK + uses: actions/setup-java@v6 + with: + distribution: zulu + java-version: 17 + - uses: gradle/actions/setup-gradle@v6 + - name: Spotless + run: ./gradlew spotlessCheck + + build: + name: Build and test + # every target is built in one job, on a mac, because the apple targets cannot be built anywhere else. + runs-on: macos-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up JDK + uses: actions/setup-java@v6 + with: + distribution: zulu + java-version: 17 + - uses: gradle/actions/setup-gradle@v6 + - name: Cache the Kotlin/Native toolchain + uses: actions/cache@v6 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- + + - name: Make Gradle executable + run: chmod +x ./gradlew + + - name: Android library, demo and benchmark + run: | + ./gradlew --stacktrace \ + :snitcher:assemble :demo:assembleDebug :benchmark:assembleBenchmarkRelease + + - name: Desktop library and demo + run: ./gradlew --stacktrace :snitcher:desktopJar :demo-desktop:assemble + + - name: iOS frameworks + run: | + ./gradlew --stacktrace \ + :snitcher:linkDebugFrameworkIosSimulatorArm64 \ + :snitcher:linkReleaseFrameworkIosArm64 + + - name: Binary compatibility + run: ./gradlew checkKotlinAbi + + - name: Tests + run: | + ./gradlew --stacktrace \ + :snitcher:testAndroidHostTest \ + :snitcher:desktopTest \ + :snitcher:iosSimulatorArm64Test + + ios_sample: + name: iOS sample + runs-on: macos-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + - name: Set up JDK + uses: actions/setup-java@v6 + with: + distribution: zulu + java-version: 17 + - uses: gradle/actions/setup-gradle@v6 + - name: Cache the Kotlin/Native toolchain + uses: actions/cache@v6 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- + + - name: Install xcodegen + run: brew install xcodegen + + - name: Generate the Xcode project + working-directory: demo-ios + run: xcodegen generate + + - name: Build the sample + run: | + xcodebuild -project demo-ios/SnitcherDemo.xcodeproj \ + -scheme SnitcherDemo \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9416cda..8400bf9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,8 @@ on: jobs: publish: name: Release build and publish - runs-on: ubuntu-latest + # the apple targets can only be built on a mac. + runs-on: macos-latest steps: - name: Check out code uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index 885180f..75b6dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,8 @@ google-services.json freeline.py freeline/ freeline_project_description.json + +# Xcode +xcuserdata/ +*.xcworkspace/ +DerivedData/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91a3b23..3e69f3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Ensure your change is properly formatted by running: Then dump binary API of this library that is public in sense of Kotlin visibilities and ensures that the public binary API wasn't changed in a way that make this change binary incompatible. ```gradle -./gradlew apiDump +./gradlew updateKotlinAbi ``` Please correct any failures before requesting a review. diff --git a/README.md b/README.md index 30f71db..881bc5c 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,30 @@

License API - Build Status + Build Status Profile


-πŸ¦‰ Snitcher captures global crashes, enabling easy redirection to the exception tracing screen for swift recovery. +πŸ¦‰ Snitcher captures global crashes on Android, desktop, and iOS, and redirects to the exception tracing screen for swift recovery.


- - + + +

+ +

+

## What is Snitcher? Snitcher offers versatile advantages such as aiding in debugging crashes during development, facilitating easy sharing of exceptions by your QA team, enhancing user experiences with recovery screens instead of abrupt closures, and enabling global exception tracing and customized launch behaviors tailored to your specific needs. You have the complete freedom to customize the crash tracing screens according to your build types and preferences, reporting to the Firebase's Crashlytics with displaying the exception screen, including options like launching a designated Activity, sending messages to your BroadcastReceiver, or any other desired actions. +The screens, the theme, and the captured model are shared across every platform with Compose Multiplatform, and each platform installs the crash capture that its runtime allows. + ## Documentation For comprehensive details about Snitcher, please refer to the **[complete documentation available here](https://skydoves.github.io/snitcher/)**. @@ -31,19 +37,49 @@ For comprehensive details about Snitcher, please refer to the **[complete docume ### Gradle -Add the dependency below to your **module**'s `build.gradle` file: +Add the dependency below to your **module**'s `build.gradle.kts` file: -```gradle +```kotlin dependencies { - implementation "com.github.skydoves:snitcher:1.0.3" + implementation("com.github.skydoves:snitcher:2.0.0") } ``` +In a multiplatform module, add it to the source set that needs it: + +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation("com.github.skydoves:snitcher:2.0.0") + } + } +} +``` + +## What each platform can capture + +| | Android | Desktop (JVM) | iOS | +| --- | --- | --- | --- | +| Uncaught JVM exceptions | yes | yes | not applicable | +| Unhandled Kotlin exceptions | yes | yes | yes | +| Uncaught `NSException` | not applicable | not applicable | yes | +| Unix signals, such as a Swift trap or a memory error | no | no | no | +| When the screen is shown | right after the crash, in a trace activity | right after the crash, in a window | on the next launch | +| Can restart the app | yes | no, the process keeps running | no, iOS does not let an app relaunch itself | + +On iOS an unhandled Kotlin exception terminates the process, so the crash is recorded and displayed on the next launch. Snitcher records it and leaves the termination to the runtime, exactly as it would happen without Snitcher. + ## Usage -Installing Snitcher is a breeze; it hooks into global exceptions, replacing application closure with informative exception tracing screens. You can seamlessly install Snitcher using the following example: +### Android + +Install Snitcher in your `Application` class. Snitcher becomes the default uncaught exception handler, persists the crash, and launches the exception tracing activity. ```kotlin +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.install + class App : Application() { override fun onCreate() { @@ -54,91 +90,94 @@ class App : Application() { } ``` -It's recommended to install Snitcher on your **Application** class or your on initialization solution, such as [App Startup](https://developer.android.com/topic/libraries/app-startup). +`install` is an extension of the `Snitcher` object that lives in each platform source set, so it needs that import. -### Tracing Global Exceptions +### Desktop -You can trace the global exceptions by providing `exceptionHandler` lambda parameter. This can be highly beneficial if you intend to gather and report exceptions to other platforms, such as [Firebase Crashlyrics](https://firebase.google.com/docs/crashlytics). +Install Snitcher before your `application { }` block, and place `SnitcherTraceWindow` next to your own window. The JVM keeps running after an uncaught exception, so the crash window opens right away. ```kotlin -Snitcher.install( - application = this, - exceptionHandler = { exception: SnitcherException -> - Firebase.crashlytics.log(exception.stackTrace) // or exception.message, +fun main() { + Snitcher.install() + + application { + Window(onCloseRequest = ::exitApplication) { + App() + } + + SnitcherTraceWindow() } -) +} ``` -The `exceptionHandler` gives you `SnitcherException`, encompassing the exception message, stack traces, package name, and thread information. Additionally, it enables you to recover the original `Throwable` instance with the `SnitcherException.throwable` extension. +### iOS -```kotlin -Snitcher.install( - application = this, - exceptionHandler = { exception: SnitcherException -> - val message: String = exception.message - val stackTrace: String = exception.stackTrace - val throwable: Throwable = exception.throwable - val threadName: String = exception.threadName +Install Snitcher when your app starts. The runtime terminates the process after a crash, so present the screen on the launch that follows it. - // do somethings - } -) -``` +```swift +import Snitcher -### Custom Exception Trace Screen +@main +struct SampleApp: App { -Snitcher provides ready-to-use exception tracing screens (such as `ExceptionTraceActivity`, built with the `ExceptionTraceScreen` Composable), giving you the flexibility to extensively tailor these screens according to your preferences, and even design your own distinct tracing interfaces. + init() { + Snitcher.shared.install() + } -```kotlin -Snitcher.install( - application = this, - traceActivity = ExceptionTraceActivity::class -) + var body: some Scene { + WindowGroup { ContentView() } + } +} ``` -If you don't specify the `traceActivity` parameter, the default value will be `ExceptionTraceActivity`. You can tailor the launched activity by modifying the `traceActivity` parameter to match your preferred choice. The example below demonstrates the construction of a customized trace Activity: +```swift +struct ContentView: View { -```kotlin -class MyExceptionTraceActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - val exception by Snitcher.exception.collectAsState() - val launcher by Snitcher.launcher.collectAsState() - - SnitcherTheme { - if (exception != null) { - if (BuildConfig.DEBUG) { - // implement your own exception trace screen - ExceptionTraceScreen( - launcher = launcher, - snitcherException = exception!!, - ) - } else { - // implement your own app restore screen - AppRestoreScreen(launcher = launcher) - } + @State private var showsCrash = Snitcher.shared.exception.value != nil + + var body: some View { + MyContent() + .fullScreenCover(isPresented: $showsCrash) { + SnitcherScreen { + Snitcher.shared.clear() + showsCrash = false } + .ignoresSafeArea() } - } } } + +struct SnitcherScreen: UIViewControllerRepresentable { + let onRestore: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + SnitcherViewControllerKt.snitcherViewController(onRestore: onRestore) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} ``` -As demonstrated in the example above, Snitcher provides access to the `SnitcherException` and the package name of the launcher Activity. This information can be utilized to construct highly customized trace screens that align with your specific needs. Snitcher conveniently provides this information through [StateFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)s, allowing you to observe these values without the need for cumbersome intent handling when initiating the trace Activity. +The [demo-ios](demo-ios) module is a complete sample, and [demo-desktop](demo-desktop) and [demo](demo) cover the other two platforms. + +### Tracing global exceptions + +Every installer takes an `exceptionHandler`, which is useful to report a crash to another platform, such as [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics). ```kotlin -val exception: SnitcherException? by Snitcher.exception.collectAsState() -val launcher: String by Snitcher.launcher.collectAsState() +Snitcher.install( + application = this, + exceptionHandler = { exception: SnitcherException -> + Firebase.crashlytics.log(exception.stackTrace) + }, +) ``` -> **Note**: Following any app crashes, you can readily observe the exception details across various components at any time and from any location. +`SnitcherException` carries the exception name, the message, the whole stack trace, and the thread information. On Android and on the desktop you can restore the original throwable with `SnitcherException.toThrowable()`. -### Custom Launcher (Restore) Activity +### Launcher (restore) Activity -Furthermore, you can customize the launcher (restore activity), specifying which Activity should be executed upon restoration within the trace activity. If you don't specify a `launcher` activity, the most recent Activity that encountered a crash will automatically be launched when users press the 'restore' button. However, if you wish to launch a particular Activity instead of the most recent one, you can accomplish this by providing the launcher parameter: +On Android you can decide which Activity is launched when the user restores the app. Without it, the most recent Activity before the crash is used. ```kotlin Snitcher.install( @@ -149,7 +188,7 @@ Snitcher.install( ### Custom Snitcher Theme -The pre-built screens are styled by a `SnitcherThemeConfig`. Give one to `Snitcher.install`, and the built-in `ExceptionTraceActivity`, as well as every screen you wrap in `SnitcherTheme`, will be drawn with it: +The pre-built screens are styled by a `SnitcherThemeConfig`. Give one to the installer, and the pre-built screens, as well as every screen you wrap in `SnitcherTheme`, will be drawn with it: ```kotlin Snitcher.install( @@ -165,138 +204,54 @@ Snitcher.install( ) ``` -`lightColors` and `darkColors` are picked by the system dark mode. Every field has a default, so you only need to declare what you want to change. - -**SnitcherColor** - -| Property | Where it is used | -|---|---| -| `primary` | The title, the section labels, and the button background | -| `onPrimary` | The content that is drawn on the buttons | -| `background` | The screen background, which is also drawn behind the system bars | -| `textHighEmphasis` | The exception message, the stack trace, and the restore screen texts | -| `textLowEmphasis` | The package and device information | -| `outline` | The border of the stack trace container | - -**SnitcherTypography** - -`title`, `message`, `deviceInfo`, `sectionLabel`, `stacktrace`, and `button` are plain `TextStyle`s, so fonts, sizes, and weights are all yours. - -**SnitcherShapes** - -`button` and `stacktrace` are plain `Shape`s. - -You can also swap the theme at any time, which restyles the screens right away: - -```kotlin -Snitcher.theme = Snitcher.theme.copy(darkColors = SnitcherColor.defaultDarkColors()) -``` - -If you build your own trace screens, wrap them in `SnitcherTheme`. Its defaults come from the installed configuration, so your screens and the pre-built ones stay in sync, and you can still override any of them for a single screen: - -```kotlin -SnitcherTheme( - colors = SnitcherTheme.colors.copy(primary = Color.Blue), -) { - if (exception != null) { - ExceptionTraceScreen( - launcher = launcher, - snitcherException = exception!!, - ) - } -} -``` - -If you wish to personalize the text strings within the pre-built UIs, you can override the following string values within your `strings.xml` file: - -```xml -Oops, Restore the previous screen? -The app crashed unexpectedly. We apologize for the inconvenience. Would you like to return to where you left off? -Restore -Restore App -Debug on IDE -Stacktrace -``` +Colors left unspecified follow the color they belong to, so `copy(primary = Color.Red)` restyles the title, the labels, the buttons, and the stack trace border together. -### Custom Build Types +### Custom texts -If you intend to launch distinct trace activities and implement different behaviors or flavors, you can install Snitcher based on specific build types, as shown in the example below: +The texts of the pre-built screens are plain strings, so they travel across platforms and you can translate them: ```kotlin Snitcher.install( application = this, - traceActivity = if (BuildConfig.DEBUG) { - MyExceptionTraceActivity::class - } else { - RestoreActivity::class - }, - exceptionHandler = { - if (!BuildConfig.DEBUG) { - Firebase.crashlytics.log(exception.stackTrace) - } - } + strings = SnitcherStrings( + traceRestoreButton = "λ‹€μ‹œ μ‹œμž‘", + traceStacktrace = "μŠ€νƒνŠΈλ ˆμ΄μŠ€", + ), ) ``` -Alternatively, you can create a single trace Activity and manage the different build types within the activity itself, as demonstrated in the example below: +### Custom trace screens + +`ExceptionTraceScreen` and `AppRestoreScreen` are plain composables, so you can build your own screen around the state that Snitcher publishes: ```kotlin -Snitcher.install( - application = this, - launcher = MyExceptionTraceActivity::class, -) +val exception by Snitcher.exception.collectAsState() -class MyExceptionTraceActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - val exception by Snitcher.exception.collectAsState() - val launcher by Snitcher.launcher.collectAsState() - - SnitcherTheme { - if (exception != null) { - if (Snitcher.isDebuggable) { - ExceptionTraceScreen( - launcher = launcher, - snitcherException = exception!!, - ) - } else { - AppRestoreScreen(launcher = launcher) - } - } - } - } +SnitcherTheme { + exception?.let { + ExceptionTraceScreen( + snitcherException = it, + onRestore = { /* restore your app */ }, + ) } } ``` -As demonstrated in the above example, you have the flexibility to create your own trace or restore Activities and install them according to your various build types. - -### Trace Strategy - -You can globally trace exceptions by providing the `exceptionHandler` lambda parameter during Snitcher installation. However, there might be instances where you don't wish to launch the trace Activity but rather perform other actions, such as reporting crashes or sending messages to a `BroadcastReceiver`. In such cases, you can modify the trace strategy as shown in the example below: - -```kotlin -Snitcher.install( - application = this, - traceStrategy = TraceStrategy.REPLACE, - exceptionHandler = { - // do something - }, -) -``` +## Migrating from 1.0.x -In this scenario, only the `exceptionHandler` lambda function will be executed without triggering the launch of any trace Activity. If the `traceStrategy` parameter is not specified, the default behavior is set to `TraceStrategy.CO_WORK`, which involves executing the `exceptionHandler` lambda and initiating the trace activity when an app crash occurs. +- `Snitcher.install(..)` is a platform extension now, so add `import com.skydoves.snitcher.install` +- The texts moved from `values/strings.xml` to `SnitcherStrings`, which the installer takes. Locale + specific `values-xx` overrides of the `snitcher_*` strings no longer apply +- `SnitcherException.throwable` became `SnitcherException.toThrowable()`, on Android and desktop +- `SnitcherException` is no longer `java.io.Serializable`, and `SnitcherInstaller` is gone +- The crash is stored in a plain file rather than in DataStore, so a crash written by 1.0.x is + not read back after the update ## Find this repository useful? :heart: - -Support it by joining __[stargazers](https://github.com/skydoves/cloudy/stargazers)__ for this repository. :star:
+Support it by joining __[stargazers](https://github.com/skydoves/snitcher/stargazers)__ for this repository. :star:
Also, __[follow me](https://github.com/skydoves)__ on GitHub for my next creations! 🀩 # License - ```xml Designed and developed by 2023 skydoves (Jaewoong Eum) @@ -304,7 +259,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/art/android_trace.png b/art/android_trace.png new file mode 100644 index 0000000..26255c0 Binary files /dev/null and b/art/android_trace.png differ diff --git a/art/demo_debug.png b/art/demo_debug.png deleted file mode 100644 index eee9a9c..0000000 Binary files a/art/demo_debug.png and /dev/null differ diff --git a/art/demo_release.png b/art/demo_release.png deleted file mode 100644 index e8f0932..0000000 Binary files a/art/demo_release.png and /dev/null differ diff --git a/art/desktop_restore.png b/art/desktop_restore.png new file mode 100644 index 0000000..53d38d9 Binary files /dev/null and b/art/desktop_restore.png differ diff --git a/art/desktop_trace.png b/art/desktop_trace.png new file mode 100644 index 0000000..5a5127a Binary files /dev/null and b/art/desktop_trace.png differ diff --git a/art/desktop_trace_dark.png b/art/desktop_trace_dark.png new file mode 100644 index 0000000..1d7ef90 Binary files /dev/null and b/art/desktop_trace_dark.png differ diff --git a/art/ios_trace.png b/art/ios_trace.png new file mode 100644 index 0000000..17b235e Binary files /dev/null and b/art/ios_trace.png differ diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index ee4fb0f..15b6c9e 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { id(libs.plugins.android.test.get().pluginId) - id(libs.plugins.kotlin.android.get().pluginId) id(libs.plugins.baseline.profile.get().pluginId) } diff --git a/build.gradle.kts b/build.gradle.kts index 84167e5..30a0a50 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,19 +16,16 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.baseline.profile) apply false alias(libs.plugins.nexus.plugin) alias(libs.plugins.spotless) alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.binary.compatibility) -} - -apiValidation { - // only the published library takes part in the binary compatibility validation. - ignoredProjects += listOf("demo", "benchmark") } subprojects { diff --git a/buildSrc/src/main/kotlin/com/skydoves/snitcher/Configuration.kt b/buildSrc/src/main/kotlin/com/skydoves/snitcher/Configuration.kt index d9a1137..d10511a 100644 --- a/buildSrc/src/main/kotlin/com/skydoves/snitcher/Configuration.kt +++ b/buildSrc/src/main/kotlin/com/skydoves/snitcher/Configuration.kt @@ -20,10 +20,10 @@ object Configuration { const val compileSdk = 37 const val targetSdk = 37 const val minSdk = 23 - const val majorVersion = 1 + const val majorVersion = 2 const val minorVersion = 0 - const val patchVersion = 3 + const val patchVersion = 0 const val versionName = "$majorVersion.$minorVersion.$patchVersion" - const val versionCode = 4 + const val versionCode = 5 const val artifactGroup = "com.github.skydoves" } diff --git a/demo-desktop/build.gradle.kts b/demo-desktop/build.gradle.kts new file mode 100644 index 0000000..31ab3d6 --- /dev/null +++ b/demo-desktop/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id(libs.plugins.kotlin.jvm.get().pluginId) + id(libs.plugins.compose.multiplatform.get().pluginId) + id(libs.plugins.compose.compiler.get().pluginId) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get())) + } +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.jvmTarget.get()) +} + +compose.desktop { + application { + mainClass = "com.skydoves.snitcherdemo.desktop.MainKt" + } +} + +dependencies { + implementation(project(":snitcher")) + implementation(compose.desktop.currentOs) +} + +/** Renders the pre-built screens into `art/` for the documentation. */ +tasks.register("generateScreenshots") { + group = "documentation" + description = "Renders the desktop Snitcher screens off screen into the art directory." + mainClass.set("com.skydoves.snitcherdemo.desktop.ScreenshotsKt") + classpath = sourceSets.getByName("main").runtimeClasspath + args("${rootDir}/art") +} diff --git a/demo-desktop/src/main/kotlin/com/skydoves/snitcherdemo/desktop/Main.kt b/demo-desktop/src/main/kotlin/com/skydoves/snitcherdemo/desktop/Main.kt new file mode 100644 index 0000000..377dbca --- /dev/null +++ b/demo-desktop/src/main/kotlin/com/skydoves/snitcherdemo/desktop/Main.kt @@ -0,0 +1,88 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcherdemo.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.install +import com.skydoves.snitcher.ui.SnitcherTraceWindow +import com.skydoves.snitcher.ui.theme.SnitcherColor +import com.skydoves.snitcher.ui.theme.SnitcherShapes +import com.skydoves.snitcher.ui.theme.SnitcherTheme +import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig +import com.skydoves.snitcher.ui.theme.SnitcherTypography + +private val Purple = Color(0xFF6650a4) + +public fun main() { + Snitcher.install( + theme = SnitcherThemeConfig( + lightColors = SnitcherColor.defaultColors().copy(primary = Purple), + darkColors = SnitcherColor.defaultDarkColors().copy(primary = Color(0xFFD0BCFF)), + typography = SnitcherTypography.defaultTypography().copy( + title = TextStyle(fontSize = 30.sp, fontWeight = FontWeight.Black), + ), + shapes = SnitcherShapes(button = RoundedCornerShape(20.dp)), + ), + exceptionHandler = { + // do something + }, + ) + + application { + Window( + onCloseRequest = ::exitApplication, + title = "Snitcher Desktop Demo", + ) { + DemoContent() + } + + SnitcherTraceWindow() + } +} + +@Composable +private fun DemoContent() { + SnitcherTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(SnitcherTheme.colors.background), + ) { + Button( + modifier = Modifier.align(Alignment.Center), + onClick = { throw RuntimeException("This is an intended runtime exception.") }, + ) { + Text(text = "Throw an exception") + } + } + } +} diff --git a/demo-desktop/src/main/kotlin/com/skydoves/snitcherdemo/desktop/Screenshots.kt b/demo-desktop/src/main/kotlin/com/skydoves/snitcherdemo/desktop/Screenshots.kt new file mode 100644 index 0000000..dc15874 --- /dev/null +++ b/demo-desktop/src/main/kotlin/com/skydoves/snitcherdemo/desktop/Screenshots.kt @@ -0,0 +1,121 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalComposeUiApi::class) + +package com.skydoves.snitcherdemo.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.ui.AppRestoreScreen +import com.skydoves.snitcher.ui.ExceptionTraceScreen +import com.skydoves.snitcher.ui.theme.SnitcherColor +import com.skydoves.snitcher.ui.theme.SnitcherShapes +import com.skydoves.snitcher.ui.theme.SnitcherTheme +import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig +import com.skydoves.snitcher.ui.theme.SnitcherTypography +import org.jetbrains.skia.EncodedImageFormat +import java.io.File + +/** Renders the pre-built screens off screen and writes them as PNG files for the documentation. */ +public fun main(args: Array) { + val outputDirectory = File(args.firstOrNull() ?: "art") + outputDirectory.mkdirs() + + Snitcher.theme = SnitcherThemeConfig( + lightColors = SnitcherColor.defaultColors().copy(primary = Color(0xFF6650a4)), + darkColors = SnitcherColor.defaultDarkColors().copy(primary = Color(0xFFD0BCFF)), + typography = SnitcherTypography.defaultTypography().copy( + title = TextStyle(fontSize = 30.sp, fontWeight = FontWeight.Black), + ), + shapes = SnitcherShapes(button = RoundedCornerShape(20.dp)), + ) + Snitcher.platformInfo = "1.0.3 (4) macOS 26.5 (JVM 21)" + + render(File(outputDirectory, "desktop_trace.png"), darkTheme = false) { + ExceptionTraceScreen( + snitcherException = sampleException, + onRestore = {}, + onDebug = {}, + ) + } + + render(File(outputDirectory, "desktop_trace_dark.png"), darkTheme = true) { + ExceptionTraceScreen( + snitcherException = sampleException, + onRestore = {}, + onDebug = {}, + ) + } + + render(File(outputDirectory, "desktop_restore.png"), darkTheme = false) { + AppRestoreScreen(onRestore = {}) + } +} + +private fun render(output: File, darkTheme: Boolean, content: @Composable () -> Unit) { + val scene = ImageComposeScene( + width = WIDTH, + height = HEIGHT, + density = Density(2f), + ) { + SnitcherTheme(darkTheme = darkTheme) { + Box(modifier = Modifier.fillMaxSize()) { + content() + } + } + } + + try { + val image = scene.render() + val data = image.encodeToData(EncodedImageFormat.PNG) + ?: error("failed to encode ${output.name}") + output.writeBytes(data.bytes) + println("wrote ${output.absolutePath}") + } finally { + scene.close() + } +} + +private const val WIDTH = 1200 +private const val HEIGHT = 1100 + +private val sampleException = SnitcherException( + threadId = 1, + threadName = "main", + packageName = "RuntimeException", + message = "This is an intended runtime exception.", + stackTrace = """ + java.lang.RuntimeException: This is an intended runtime exception. + at com.skydoves.snitcherdemo.desktop.MainKt.DemoContent(Main.kt:74) + at androidx.compose.foundation.ClickableNode.handleUpEvent(Clickable.kt:950) + at androidx.compose.ui.input.pointer.Node.dispatchMainEventPass(HitPathTracker.kt:445) + at androidx.compose.ui.platform.ComposeSceneMediator.processPointerInput(Mediator.kt:381) + at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:92) + """.trimIndent(), +) diff --git a/demo-ios/Info.plist b/demo-ios/Info.plist new file mode 100644 index 0000000..ab4dfd2 --- /dev/null +++ b/demo-ios/Info.plist @@ -0,0 +1,32 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Snitcher + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.3 + CFBundleVersion + 4 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/demo-ios/SnitcherDemo.xcodeproj/project.pbxproj b/demo-ios/SnitcherDemo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..16d44fe --- /dev/null +++ b/demo-ios/SnitcherDemo.xcodeproj/project.pbxproj @@ -0,0 +1,327 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 728D9A7A0D124DC53C15A6E7 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E72BA346466D183904C6D0 /* iOSApp.swift */; }; + B3D90AEE3675C33DE55DF8A2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF1ACEE89A074AEB98E7ABE /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 015F31CD5E844EC5F4F4AE26 /* SnitcherDemo.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = SnitcherDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 62E72BA346466D183904C6D0 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + BAF1ACEE89A074AEB98E7ABE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 08AF48FDF21EA677E44FDEEF = { + isa = PBXGroup; + children = ( + B4DF3DA3EB64365F3FEF038E /* Sources */, + BD43363266B25F3511410307 /* Products */, + ); + sourceTree = ""; + }; + B4DF3DA3EB64365F3FEF038E /* Sources */ = { + isa = PBXGroup; + children = ( + BAF1ACEE89A074AEB98E7ABE /* ContentView.swift */, + 62E72BA346466D183904C6D0 /* iOSApp.swift */, + ); + path = Sources; + sourceTree = ""; + }; + BD43363266B25F3511410307 /* Products */ = { + isa = PBXGroup; + children = ( + 015F31CD5E844EC5F4F4AE26 /* SnitcherDemo.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 910AE3A3BAF6E8E918569B5F /* SnitcherDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = C4F2C5C18DE32EE417DF35F2 /* Build configuration list for PBXNativeTarget "SnitcherDemo" */; + buildPhases = ( + C89FF8CC558500BF625DAADF /* Build the Snitcher framework */, + 3C426FD3F0418B5272FE0EB4 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SnitcherDemo; + packageProductDependencies = ( + ); + productName = SnitcherDemo; + productReference = 015F31CD5E844EC5F4F4AE26 /* SnitcherDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E5107465666EAE2A378643C2 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = C53E2F567FE65D9C9634D0D8 /* Build configuration list for PBXProject "SnitcherDemo" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 08AF48FDF21EA677E44FDEEF; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = BD43363266B25F3511410307 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 910AE3A3BAF6E8E918569B5F /* SnitcherDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + C89FF8CC558500BF625DAADF /* Build the Snitcher framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build the Snitcher framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd \"$SRCROOT/..\"\n./gradlew :snitcher:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 3C426FD3F0418B5272FE0EB4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B3D90AEE3675C33DE55DF8A2 /* ContentView.swift in Sources */, + 728D9A7A0D124DC53C15A6E7 /* iOSApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 518F275F155BAC1105898AE9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../snitcher/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Snitcher, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.skydoves.snitcherdemo; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8A977199B758CB9A89426009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 952B37DF4B6183F9441ADD30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../snitcher/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Snitcher, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.skydoves.snitcherdemo; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FCDABE18D1AD844F18BD9CF9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C4F2C5C18DE32EE417DF35F2 /* Build configuration list for PBXNativeTarget "SnitcherDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 952B37DF4B6183F9441ADD30 /* Debug */, + 518F275F155BAC1105898AE9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C53E2F567FE65D9C9634D0D8 /* Build configuration list for PBXProject "SnitcherDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8A977199B758CB9A89426009 /* Debug */, + FCDABE18D1AD844F18BD9CF9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = E5107465666EAE2A378643C2 /* Project object */; +} diff --git a/demo-ios/Sources/ContentView.swift b/demo-ios/Sources/ContentView.swift new file mode 100644 index 0000000..3f8b5bf --- /dev/null +++ b/demo-ios/Sources/ContentView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import UIKit +import Snitcher + +struct ContentView: View { + + @State private var showsCrash = Snitcher.shared.exception.value != nil + + var body: some View { + VStack(spacing: 24) { + Text("Snitcher") + .font(.largeTitle.bold()) + + Button("Raise an exception") { + raiseDemoException() + } + .buttonStyle(.borderedProminent) + } + .onAppear { + // lets an automated run reproduce a crash without a tap, which is how the screenshots in the + // documentation are taken. + if ProcessInfo.processInfo.arguments.contains("--crash-on-launch") { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { raiseDemoException() } + } + } + .fullScreenCover(isPresented: $showsCrash) { + SnitcherScreen(onRestore: { + Snitcher.shared.clear() + showsCrash = false + }) + .ignoresSafeArea() + } + } +} + +private func raiseDemoException() { + NSException( + name: NSExceptionName("RuntimeException"), + reason: "This is an intended runtime exception.", + userInfo: nil + ).raise() +} + +/// Wraps the UIViewController that Snitcher builds so that SwiftUI can present it. +struct SnitcherScreen: UIViewControllerRepresentable { + + let onRestore: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + SnitcherViewControllerKt.snitcherViewController(onRestore: onRestore) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/demo-ios/Sources/iOSApp.swift b/demo-ios/Sources/iOSApp.swift new file mode 100644 index 0000000..db38ebd --- /dev/null +++ b/demo-ios/Sources/iOSApp.swift @@ -0,0 +1,18 @@ +import SwiftUI +import Snitcher + +@main +struct SnitcherDemoApp: App { + + init() { + // The defaults are enough here. Every parameter can be given explicitly instead, which is how + // you pass your own theme, texts, or an extra exception handler. + Snitcher.shared.install() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/demo-ios/project.yml b/demo-ios/project.yml new file mode 100644 index 0000000..7c22ffc --- /dev/null +++ b/demo-ios/project.yml @@ -0,0 +1,42 @@ +name: SnitcherDemo +options: + bundleIdPrefix: com.skydoves + deploymentTarget: + iOS: "16.0" + createIntermediateGroups: true +settings: + base: + SWIFT_VERSION: "5.0" +targets: + SnitcherDemo: + type: application + platform: iOS + sources: + - Sources + info: + path: Info.plist + properties: + CFBundleDisplayName: Snitcher + CFBundleShortVersionString: "1.0.3" + CFBundleVersion: "4" + CADisableMinimumFrameDurationOnPhone: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.skydoves.snitcherdemo + FRAMEWORK_SEARCH_PATHS: + - $(inherited) + - $(SRCROOT)/../snitcher/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME) + OTHER_LDFLAGS: + - $(inherited) + - -framework + - Snitcher + ENABLE_USER_SCRIPT_SANDBOXING: NO + preBuildScripts: + - name: Build the Snitcher framework + basedOnDependencyAnalysis: false + script: | + cd "$SRCROOT/.." + ./gradlew :snitcher:embedAndSignAppleFrameworkForXcode diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index c0bec22..1fa6c7d 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -18,7 +18,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget @Suppress("DSL_SCOPE_VIOLATION") plugins { id(libs.plugins.android.application.get().pluginId) - id(libs.plugins.kotlin.android.get().pluginId) id(libs.plugins.compose.compiler.get().pluginId) id(libs.plugins.baseline.profile.get().pluginId) } diff --git a/demo/src/main/kotlin/com/skydoves/snitcherdemo/SnitcherDemoApp.kt b/demo/src/main/kotlin/com/skydoves/snitcherdemo/SnitcherDemoApp.kt index 055226c..87c8e72 100644 --- a/demo/src/main/kotlin/com/skydoves/snitcherdemo/SnitcherDemoApp.kt +++ b/demo/src/main/kotlin/com/skydoves/snitcherdemo/SnitcherDemoApp.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.install import com.skydoves.snitcher.ui.theme.SnitcherColor import com.skydoves.snitcher.ui.theme.SnitcherShapes import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 0000000..efb7beb --- /dev/null +++ b/docs/android.md @@ -0,0 +1,43 @@ +# Android + +Install Snitcher in your `Application` class. Snitcher becomes the default uncaught exception handler, persists the crash synchronously, launches the exception tracing activity, and kills the crashed process. + +```kotlin +class App : Application() { + + override fun onCreate() { + super.onCreate() + + Snitcher.install(application = this) + } +} +``` + +`install` is an extension of the `Snitcher` object that lives in the Android source set, so import it explicitly: + +```kotlin +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.install +``` + +## What the user sees + +The bundled `ExceptionTraceActivity` displays the full [exception trace screen](custom-trace-screen.md) on a debuggable build, and the friendly restore screen otherwise. The decision comes from `Snitcher.isDebuggable`, which is taken from the application's debuggable flag and can be overridden: + +```kotlin +Snitcher.isDebuggable = BuildConfig.DEBUG +``` + +## Where the crash is stored + +The crash is written to `filesDir/snitcher.json` before the process is killed, and it is read back on the next launch, so `Snitcher.exception` is never empty right after a crash. + +Clear it once you have handled it: + +```kotlin +Snitcher.clear() +``` + +## Requirements + +Android 6.0 (API 23) and above, which is the floor of the Compose and AndroidX libraries that Snitcher builds on. diff --git a/docs/custom-build-type.md b/docs/custom-build-type.md index a28367e..09809a3 100644 --- a/docs/custom-build-type.md +++ b/docs/custom-build-type.md @@ -10,11 +10,11 @@ Snitcher.install( } else { RestoreActivity::class }, - exceptionHandler = { + exceptionHandler = { exception -> if (!BuildConfig.DEBUG) { Firebase.crashlytics.log(exception.stackTrace) } - } + }, ) ``` @@ -23,7 +23,7 @@ Alternatively, you can create a single trace Activity and manage the different b ```kotlin Snitcher.install( application = this, - launcher = MyExceptionTraceActivity::class, + traceActivity = MyExceptionTraceActivity::class, ) class MyExceptionTraceActivity : ComponentActivity() { @@ -36,12 +36,9 @@ class MyExceptionTraceActivity : ComponentActivity() { val launcher by Snitcher.launcher.collectAsState() SnitcherTheme { - if (exception != null) { + exception?.let { if (Snitcher.isDebuggable) { - ExceptionTraceScreen( - launcher = launcher, - snitcherException = exception!!, - ) + ExceptionTraceScreen(launcher = launcher, snitcherException = it) } else { AppRestoreScreen(launcher = launcher) } @@ -52,4 +49,8 @@ class MyExceptionTraceActivity : ComponentActivity() { } ``` -As demonstrated in the above example, you have the flexibility to create your own trace or restore Activities and install them according to your various build types. +`Snitcher.isDebuggable` is taken from the application's debuggable flag on Android, from the binary kind on iOS, and is true on the desktop. Assign it yourself to decide which screen is displayed: + +```kotlin +Snitcher.isDebuggable = BuildConfig.DEBUG +``` diff --git a/docs/custom-launcher-activity.md b/docs/custom-launcher-activity.md index a98298a..3a5fac8 100644 --- a/docs/custom-launcher-activity.md +++ b/docs/custom-launcher-activity.md @@ -1,10 +1,18 @@ # Launcher (Restore) Activity -Furthermore, you can customize the launcher (restore activity), specifying which Activity should be executed upon restoration within the trace activity. If you don't specify a `launcher` activity, the most recent Activity that encountered a crash will automatically be launched when users press the 'restore' button. However, if you wish to launch a particular Activity instead of the most recent one, you can accomplish this by providing the launcher parameter: +This one is Android only, since an application cannot relaunch itself on iOS, and the desktop process keeps running after a crash. + +You can customize the launcher, which specifies the Activity that is started when the user restores the app from the trace screen. If you don't specify a `launcher`, the most recent Activity before the crash is launched: ```kotlin Snitcher.install( application = this, launcher = MainActivity::class, ) -``` \ No newline at end of file +``` + +The launcher is published as a `StateFlow`, so your own trace screen can read it: + +```kotlin +val launcher: String by Snitcher.launcher.collectAsState() +``` diff --git a/docs/custom-theme.md b/docs/custom-theme.md index bc7a754..7bba394 100644 --- a/docs/custom-theme.md +++ b/docs/custom-theme.md @@ -1,6 +1,6 @@ # Snitcher Theme -The pre-built screens are styled by a `SnitcherThemeConfig`. Give one to `Snitcher.install`, and the built-in `ExceptionTraceActivity`, as well as every screen you wrap in `SnitcherTheme`, will be drawn with it: +The pre-built screens are styled by a `SnitcherThemeConfig`. Give one to the platform installer, and the built-in `ExceptionTraceActivity`, as well as every screen you wrap in `SnitcherTheme`, will be drawn with it: ```kotlin Snitcher.install( @@ -16,7 +16,7 @@ Snitcher.install( ) ``` -`lightColors` and `darkColors` are picked by the system dark mode. Every field has a default, so you only need to declare what you want to change. +`lightColors` and `darkColors` are picked by the system dark mode. Start from `defaultColors()`, `defaultDarkColors()` and `defaultTypography()` and `copy` what you want to change, since a palette and a typography carry no partial defaults. **SnitcherColor** @@ -47,24 +47,36 @@ If you build your own trace screens, wrap them in `SnitcherTheme`. Its defaults ```kotlin SnitcherTheme( - colors = SnitcherTheme.colors.copy(primary = Color.Blue), + colors = Snitcher.theme.lightColors.copy(primary = Color.Blue), ) { - if (exception != null) { - ExceptionTraceScreen( - launcher = launcher, - snitcherException = exception!!, - ) + exception?.let { + ExceptionTraceScreen(snitcherException = it, onRestore = { Snitcher.clear() }) } } ``` -If you wish to personalize the text strings within the pre-built UIs, you can override the following string values within your `strings.xml` file: +`SnitcherTheme.colors` reads the theme of the surrounding composition, so build the argument from `Snitcher.theme` rather than from `SnitcherTheme.colors` when you call `SnitcherTheme` yourself. -```xml -Oops, Restore the previous screen? -The app crashed unexpectedly. We apologize for the inconvenience. Would you like to return to where you left off? -Restore -Restore App -Debug on IDE -Stacktrace +The texts of the pre-built screens are plain strings rather than platform resources, so they are shared across platforms. Give a `SnitcherStrings` to the installer to translate or reword them: + +```kotlin +Snitcher.install( + application = this, + strings = SnitcherStrings( + restoreTitle = "Oops, Restore the previous screen?", + restoreDescription = "The app crashed unexpectedly. Would you like to return to where you left off?", + restoreButton = "Restore", + traceRestoreButton = "Restore App", + traceDebugButton = "Debug on IDE", + traceStacktrace = "Stacktrace", + traceCopied = "Copied!", + ), +) +``` + +Both can also be replaced at any time, which restyles the screens right away: + +```kotlin +Snitcher.theme = Snitcher.theme.copy(darkColors = SnitcherColor.defaultDarkColors()) +Snitcher.strings = SnitcherStrings(traceStacktrace = "Stack trace") ``` diff --git a/docs/custom-trace-screen.md b/docs/custom-trace-screen.md index f7c497c..bce45f8 100644 --- a/docs/custom-trace-screen.md +++ b/docs/custom-trace-screen.md @@ -1,36 +1,55 @@ # Exception Trace Screen -Snitcher provides ready-to-use exception tracing screens (such as `ExceptionTraceActivity`, built with the `ExceptionTraceScreen` Composable), giving you the flexibility to extensively tailor these screens according to your preferences, and even design your own distinct tracing interfaces. +Snitcher provides ready-to-use screens, `ExceptionTraceScreen` and `AppRestoreScreen`, which are Compose Multiplatform composables and render the same way on every platform. Each platform also gives you a host for them, and you are free to replace any of it. + +## The screens + +```kotlin +@Composable +public fun ExceptionTraceScreen( + snitcherException: SnitcherException, + modifier: Modifier = Modifier, + onRestore: (() -> Unit)? = null, + onDebug: (() -> Unit)? = null, +) + +@Composable +public fun AppRestoreScreen( + modifier: Modifier = Modifier, + onRestore: (() -> Unit)? = null, +) +``` + +A button is hidden when its callback is null, which is how the screens adapt to what a platform supports. iOS cannot restore a throwable, for instance, so it never shows the debug button. + +## Android + +The bundled `ExceptionTraceActivity` is launched after a crash. Replace it with your own by giving the `traceActivity` parameter: ```kotlin Snitcher.install( application = this, - traceActivity = ExceptionTraceActivity::class + traceActivity = MyExceptionTraceActivity::class, ) ``` -If you don't specify the `traceActivity` parameter, the default value will be `ExceptionTraceActivity`. You can tailor the launched activity by modifying the `traceActivity` parameter to match your preferred choice. The example below demonstrates the construction of a customized trace Activity: - ```kotlin class MyExceptionTraceActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() setContent { val exception by Snitcher.exception.collectAsState() val launcher by Snitcher.launcher.collectAsState() SnitcherTheme { - if (exception != null) { + exception?.let { if (BuildConfig.DEBUG) { - // implement your own exception trace screen - ExceptionTraceScreen( - launcher = launcher, - snitcherException = exception!!, - ) + // the android overload wires the restore and debug actions for you + ExceptionTraceScreen(launcher = launcher, snitcherException = it) } else { - // implement your own app restore screen AppRestoreScreen(launcher = launcher) } } @@ -40,11 +59,40 @@ class MyExceptionTraceActivity : ComponentActivity() { } ``` -As demonstrated in the example above, Snitcher provides access to the `SnitcherException` and the package name of the launcher Activity. This information can be utilized to construct highly customized trace screens that align with your specific needs. Snitcher conveniently provides this information through [StateFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)s, allowing you to observe these values without the need for cumbersome intent handling when initiating the trace Activity. +## Desktop + +`SnitcherTraceWindow` opens a window whenever there is a crash. Build your own window instead when you want a different shell: ```kotlin -val exception: SnitcherException? by Snitcher.exception.collectAsState() -val launcher: String by Snitcher.launcher.collectAsState() +application { + val exception by Snitcher.exception.collectAsState() + + exception?.let { + Window(onCloseRequest = { Snitcher.clear() }, title = "Crash") { + SnitcherTheme { + ExceptionTraceScreen( + snitcherException = it, + onRestore = { Snitcher.clear() }, + onDebug = { Snitcher.debug(it) }, + ) + } + } + } +} ``` -> **Note**: Following any app crashes, you can readily observe the exception details across various components at any time and from any location. +## iOS + +`snitcherViewController(onRestore:)` wraps the screens in a `UIViewController`. Build your own controller when you want to embed the screen in your own navigation: + +```kotlin +fun myCrashViewController(): UIViewController = ComposeUIViewController { + val exception by Snitcher.exception.collectAsState() + + SnitcherTheme { + exception?.let { + ExceptionTraceScreen(snitcherException = it, onRestore = { Snitcher.clear() }) + } + } +} +``` diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..b771c41 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,46 @@ +# Desktop + +Install Snitcher before your `application { }` block, and place `SnitcherTraceWindow` next to your own window: + +```kotlin +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.install +import com.skydoves.snitcher.ui.SnitcherTraceWindow + +fun main() { + Snitcher.install() + + application { + Window(onCloseRequest = ::exitApplication) { + App() + } + + SnitcherTraceWindow() + } +} +``` + +

+ + +

+ +Snitcher becomes the default uncaught exception handler of the JVM and delegates to the handler that was installed before it, so a logging handler keeps working. + +Unlike Android, the desktop JVM keeps running after an uncaught exception on a background thread or on the AWT event thread. The crash is published through `Snitcher.exception` right away, the window opens, and closing it clears the crash. + +## Where the crash is stored + +`~/.snitcher/snitcher.json` by default, which you can change: + +```kotlin +Snitcher.install(storageDirectory = "/path/that/your/app/owns") +``` + +## Throwing the exception again + +The trace window shows a **Debug on IDE** button, which throws the captured exception again so that an attached debugger catches it. You can do the same yourself: + +```kotlin +Snitcher.debug(exception) +``` diff --git a/docs/index.md b/docs/index.md index 99dc25e..dab75d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,17 @@ # Overview -πŸ¦‰ Snitcher captures global crashes, enabling easy redirection to the exception tracing screen for swift recovery. +πŸ¦‰ Snitcher captures global crashes on Android, desktop, and iOS, and redirects to the exception tracing screen for swift recovery. + +

+ + +

## What is Snitcher? -Snitcher offers versatile advantages such as aiding in debugging crashes during development, facilitating easy sharing of exceptions by your QA team, enhancing user experiences with recovery screens instead of abrupt closures, and enabling global exception tracing and customized launch behaviors tailored to your specific needs. You have the complete freedom to customize the crash tracing screens according to your build types and preferences, reporting to the Firebase's Crashlytics with displaying the exception screen, including options like launching a designated Activity, sending messages to your BroadcastReceiver, or any other desired actions. +Snitcher offers versatile advantages such as aiding in debugging crashes during development, facilitating easy sharing of exceptions by your QA team, enhancing user experiences with recovery screens instead of abrupt closures, and enabling global exception tracing and customized launch behaviors tailored to your specific needs. + +The screens, the theme, and the captured model are shared across every platform with Compose Multiplatform, and each platform installs the crash capture that its runtime allows. ## Download @@ -12,41 +19,41 @@ Snitcher offers versatile advantages such as aiding in debugging crashes during ### Gradle -Add the dependency below to your **module**'s `build.gradle` file: - -=== "Groovy" - - ```Groovy - dependencies { - implementation "com.github.skydoves:snitcher:$version" - } - ``` - -=== "KTS" - - ```kotlin - dependencies { - implementation("com.github.skydoves:snitcher:$version") - } - ``` - -## Usage - -Installing Snitcher is a breeze; it hooks into global exceptions, replacing application closure with informative exception tracing screens. You can seamlessly install Snitcher using the following example: +Add the dependency below to your **module**'s `build.gradle.kts` file: ```kotlin -class App : Application() { +dependencies { + implementation("com.github.skydoves:snitcher:2.0.0") +} +``` - override fun onCreate() { - super.onCreate() +In a multiplatform module, add it to the source set that needs it: - Snitcher.install(application = this) +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation("com.github.skydoves:snitcher:2.0.0") + } } } ``` -Upon completing the project build, in the event of encountering any exceptions, you will be presented with the screens illustrated below, tailored to your specific build types, rather than experiencing an abrupt application closure: +## What each platform can capture + +| | Android | Desktop (JVM) | iOS | +| --- | --- | --- | --- | +| Uncaught JVM exceptions | yes | yes | not applicable | +| Unhandled Kotlin exceptions | yes | yes | yes | +| Uncaught `NSException` | not applicable | not applicable | yes | +| Unix signals, such as a Swift trap or a memory error | no | no | no | +| When the screen is shown | right after the crash, in a trace activity | right after the crash, in a window | on the next launch | +| Can restart the app | yes | no, the process keeps running | no, iOS does not let an app relaunch itself | + +Snitcher never installs a signal handler. A crash that arrives as `SIGSEGV`, `SIGABRT`, or as a Swift runtime trap is out of reach for an in-process Kotlin handler, and belongs to a crash reporter such as KSCrash. + +## Platform guides -| Debug | Release | -| ------- | ------- | -| ![debug](https://github.com/skydoves/skydoves/assets/24237865/f0a47ac1-95a3-42c8-913a-81859f458a37) | ![release](https://github.com/skydoves/skydoves/assets/24237865/8a3bbc95-7441-4af2-ae4c-3b0ba9de3171) | \ No newline at end of file +- [Android](android.md) +- [Desktop](desktop.md) +- [iOS](ios.md) diff --git a/docs/ios.md b/docs/ios.md new file mode 100644 index 0000000..d09302a --- /dev/null +++ b/docs/ios.md @@ -0,0 +1,141 @@ +# iOS + +Snitcher records a crash on iOS and leaves the termination to the runtime, because an unhandled Kotlin exception terminates the process. The crash is written to disk synchronously and displayed on the launch that follows it. + +Snitcher keeps the runtime behaviour intact: when no other hook was installed, the recorded crash ends in `terminateWithUnhandledException`, which is exactly what happens without Snitcher, so the system still writes its own crash report. A hook that was installed before Snitcher is chained instead. + +## Set up the framework + +Add Snitcher to the shared module that your iOS app already links, and build a framework out of it: + +```kotlin +kotlin { + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = "Shared" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation("com.github.skydoves:snitcher:2.0.0") + } + } +} +``` + +Then add a run script build phase to the Xcode target, before the compile phase: + +```bash +cd "$SRCROOT/.." +./gradlew :shared:embedAndSignAppleFrameworkForXcode +``` + +The [demo-ios](https://github.com/skydoves/snitcher/tree/main/demo-ios) sample links the Snitcher framework directly instead, and its Xcode project is generated from `project.yml` with [xcodegen](https://github.com/yonaskolb/XcodeGen): + +```bash +cd demo-ios && xcodegen generate +``` + +## Install + +```swift +import Snitcher + +@main +struct SampleApp: App { + + init() { + Snitcher.shared.install() + } + + var body: some Scene { + WindowGroup { ContentView() } + } +} +``` + +`Snitcher.shared.install()` uses the defaults. Kotlin default arguments do not cross the Objective-C boundary, so the fully configurable entry point takes every parameter: + +```swift +Snitcher.shared.install( + storageDirectory: NSTemporaryDirectory(), + theme: SnitcherThemeConfig.companion.defaultThemeConfig(), + strings: SnitcherStrings.companion.defaultStrings(), + platformInfo: "1.0.0 iPhone", + catchObjectiveCExceptions: true, + exceptionHandler: { exception in + print(exception.stackTrace) + } +) +``` + +From Kotlin, the same installer takes default arguments as usual: + +```kotlin +Snitcher.install( + theme = SnitcherThemeConfig(lightColors = SnitcherColor.defaultColors()), +) +``` + +## Present the screen + +```swift +struct ContentView: View { + + @State private var showsCrash = Snitcher.shared.exception.value != nil + + var body: some View { + MyContent() + .fullScreenCover(isPresented: $showsCrash) { + SnitcherScreen { + Snitcher.shared.clear() + showsCrash = false + } + .ignoresSafeArea() + } + } +} + +struct SnitcherScreen: UIViewControllerRepresentable { + let onRestore: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + SnitcherViewControllerKt.snitcherViewController(onRestore: onRestore) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} +``` + +The bundled controller renders the same Compose screens as the other platforms. Build your own screen instead by observing `Snitcher.exception` and calling `ExceptionTraceScreen` from a `ComposeUIViewController`. + +## What is captured, and what is not + +| Kind of crash | Captured | +| --- | --- | +| Unhandled Kotlin exception, including one crossing into Swift | yes, through `setUnhandledExceptionHook` | +| Unhandled exception inside a coroutine | yes, and the process terminates as it would without Snitcher | +| Uncaught `NSException` raised by Swift or Objective-C | yes, through `NSSetUncaughtExceptionHandler` | +| Swift runtime trap, such as `fatalError` or a nil force unwrap | no, it raises a signal | +| Memory errors, `SIGSEGV`, `SIGABRT` | no, it raises a signal | +| Watchdog terminations and out of memory kills | no | + +Pass `catchObjectiveCExceptions = false` if another crash reporter owns `NSSetUncaughtExceptionHandler`. Snitcher keeps and calls the handler that was installed before it in either case. + +The stack trace is a list of symbol strings rather than structured frames, which is what Kotlin/Native exposes, so `SnitcherException.stackTraceElement` is empty on iOS. Symbol names in a release build need the dSYM, or the `sourceInfoType=libbacktrace` binary option: + +```kotlin +kotlin { + targets.withType().configureEach { + binaries.all { + binaryOption("sourceInfoType", "libbacktrace") + } + } +} +``` + +## Where the crash is stored + +The caches directory of the app by default, which you can change with `storageDirectory`. diff --git a/docs/trace-global-exception.md b/docs/trace-global-exception.md index 3ad4df5..07e77a3 100644 --- a/docs/trace-global-exception.md +++ b/docs/trace-global-exception.md @@ -1,28 +1,66 @@ # Tracing Global Exceptions -You can trace the global exceptions by providing `exceptionHandler` lambda parameter. This can be highly beneficial if you intend to gather and report exceptions to other platforms, such as [Firebase Crashlyrics](https://firebase.google.com/docs/crashlytics). +Every platform installer takes an `exceptionHandler` lambda, which runs with the captured crash before the process goes down. It is useful to report exceptions to another platform, such as [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics). ```kotlin Snitcher.install( application = this, exceptionHandler = { exception: SnitcherException -> - Firebase.crashlytics.log(exception.stackTrace) // or exception.message, - } + Firebase.crashlytics.log(exception.stackTrace) + }, ) ``` -The `exceptionHandler` gives you `SnitcherException`, encompassing the exception message, stack traces, package name, and thread information. Additionally, it enables you to recover the original `Throwable` instance with the `SnitcherException.throwable` extension. +The handler runs on the crashing thread, before the process is killed, so keep it short. Blocking on the network there delays, or prevents, the trace screen. + +The handler gives you a `SnitcherException`, which carries the exception name, the message, the whole stack trace, and the thread information. ```kotlin Snitcher.install( application = this, exceptionHandler = { exception: SnitcherException -> + val name: String = exception.packageName val message: String = exception.message val stackTrace: String = exception.stackTrace - val throwable: Throwable = exception.throwable val threadName: String = exception.threadName - // do somethings - } + // do something + }, +) +``` + +On Android and on the desktop the original throwable can be restored, which is handy for reporters that take a `Throwable`: + +```kotlin +val throwable: Throwable = exception.toThrowable() +``` + +Kotlin/Native does not expose structured stack trace frames, so `toThrowable()` is only available on Android and on the desktop, and `SnitcherException.stackTraceElement` is empty on iOS. The whole trace is always available as a string. + +## Chaining with another crash reporter + +On Android, Snitcher delegates to the handler that was installed before it, so a reporter keeps working. Two handlers are skipped on purpose: the one from the Android runtime and the one from Firebase Crashlytics, because both kill the process and the trace screen would never appear. Report from `exceptionHandler` instead: + +```kotlin +Snitcher.install( + application = this, + exceptionHandler = { exception -> + Firebase.crashlytics.recordException(exception.toThrowable()) + }, ) -``` \ No newline at end of file +``` + +## Observing the crash anywhere + +Snitcher publishes the most recent crash as a `StateFlow`, so any part of your app can observe it, including on the launch that follows a crash. + +```kotlin +val exception: SnitcherException? by Snitcher.exception.collectAsState() +val launcher: String by Snitcher.launcher.collectAsState() +``` + +Once you have handled it, clear it so that it is not displayed again: + +```kotlin +Snitcher.clear() +``` diff --git a/docs/trace-strategy.md b/docs/trace-strategy.md index c620aef..26df8a8 100644 --- a/docs/trace-strategy.md +++ b/docs/trace-strategy.md @@ -1,5 +1,7 @@ # Trace Strategy +This one is Android only, since it decides whether the trace Activity is launched after a crash. + You can globally trace exceptions by providing the `exceptionHandler` lambda parameter during Snitcher installation. However, there might be instances where you don't wish to launch the trace Activity but rather perform other actions, such as reporting crashes or sending messages to a `BroadcastReceiver`. In such cases, you can modify the trace strategy as shown in the example below: ```kotlin @@ -12,4 +14,4 @@ Snitcher.install( ) ``` -In this scenario, only the `exceptionHandler` lambda function will be executed without triggering the launch of any trace Activity. If the `traceStrategy` parameter is not specified, the default behavior is set to `TraceStrategy.CO_WORK`, which involves executing the `exceptionHandler` lambda and initiating the trace activity when an app crash occurs. \ No newline at end of file +In this scenario, only the `exceptionHandler` lambda function will be executed without triggering the launch of any trace Activity. If the `traceStrategy` parameter is not specified, the default behavior is set to `TraceStrategy.CO_WORK`, which involves executing the `exceptionHandler` lambda and initiating the trace activity when an app crash occurs. diff --git a/gradle.properties b/gradle.properties index 909adc4..ec8cfd9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,13 +31,6 @@ org.gradle.configureondemand=true # AndroidX Migration https://developer.android.com/jetpack/androidx/migrate android.useAndroidX=true -# Built-in Kotlin of AGP 9 does not expose the Android compilations to the binary compatibility -# validator nor to the ABI validation of the Kotlin Gradle plugin, which silently turns apiDump and -# apiCheck into no-ops. Keep the Kotlin Android plugin, which also requires the legacy DSL, until -# https://youtrack.jetbrains.com/issue/KT-83410 ships. -android.builtInKotlin=false -android.newDsl=false - # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5f6093..d563c2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,15 +3,15 @@ agp = "9.4.0" dokka = "2.2.0" nexusPlugin = "0.37.0" kotlin = "2.4.20" -kotlinBinaryCompatibility = "0.18.2" kotlinSerialization = "1.11.0" jvmTarget = "11" androidxComposeBom = "2026.09.00" androidxComposeConstraintLayout = "1.1.2" androidxActivity = "1.13.0" androidxCore = "1.19.0" +composeMultiplatform = "1.12.0" +okio = "3.18.2" androidxTest = "1.7.0" -androidxDataStore = "1.2.1" coroutines = "1.11.0" baselineProfiles = "1.4.1" uiAutomator = "2.4.0" @@ -24,12 +24,14 @@ androidxMacroBenchmark = "1.5.0" android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } nexus-plugin = { id = "com.vanniktech.maven.publish", version.ref = "nexusPlugin" } -kotlin-binary-compatibility = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlinBinaryCompatibility" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } baseline-profile = { id = "androidx.baselineprofile", version.ref = "androidxMacroBenchmark" } @@ -44,11 +46,15 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "androidxComposeConstraintLayout" } -androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } -androidx-datastore-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "androidxDataStore" } +okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } +compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "composeMultiplatform" } +compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "composeMultiplatform" } +compose-material = { group = "org.jetbrains.compose.material", name = "material", version.ref = "composeMultiplatform" } +compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "composeMultiplatform" } +kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinSerialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerialization" } -kotlinx-serialization-protobuf = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-protobuf", version.ref = "kotlinSerialization" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } # unit test androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" } diff --git a/mkdocs.yml b/mkdocs.yml index 3fb6e1a..f84090a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,9 @@ docs_dir: docs # Navigation nav: - 'Overview': index.md + - 'Android': android.md + - 'Desktop': desktop.md + - 'iOS': ios.md - 'Trace Global Exception': trace-global-exception.md - 'Exception Screen': custom-trace-screen.md - 'Launcher Activity': custom-launcher-activity.md diff --git a/settings.gradle.kts b/settings.gradle.kts index 6574cf7..54391a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,5 +28,6 @@ dependencyResolutionManagement { } rootProject.name = "SnitcherDemo" include(":demo") +include(":demo-desktop") include(":snitcher") include(":benchmark") diff --git a/snitcher/api/android/snitcher.api b/snitcher/api/android/snitcher.api new file mode 100644 index 0000000..8b9ecba --- /dev/null +++ b/snitcher/api/android/snitcher.api @@ -0,0 +1,291 @@ +public final class com/skydoves/snitcher/Launcher_androidKt { + public static final fun getLauncher (Lcom/skydoves/snitcher/Snitcher;)Lkotlinx/coroutines/flow/StateFlow; +} + +public final class com/skydoves/snitcher/Snitcher { + public static final field $stable I + public static final field INSTANCE Lcom/skydoves/snitcher/Snitcher; + public final fun clear ()V + public final fun getException ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getLatestException ()Lcom/skydoves/snitcher/model/SnitcherException; + public final fun getPlatformInfo ()Ljava/lang/String; + public final fun getStrings ()Lcom/skydoves/snitcher/SnitcherStrings; + public final fun getTheme ()Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig; + public final fun isDebuggable ()Z + public final fun isInstalled ()Z + public final fun setDebuggable (Z)V + public final fun setPlatformInfo (Ljava/lang/String;)V + public final fun setStrings (Lcom/skydoves/snitcher/SnitcherStrings;)V + public final fun setTheme (Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;)V +} + +public final class com/skydoves/snitcher/SnitcherStrings { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/SnitcherStrings$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/skydoves/snitcher/SnitcherStrings; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/SnitcherStrings;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/skydoves/snitcher/SnitcherStrings; + public fun equals (Ljava/lang/Object;)Z + public final fun getRestoreButton ()Ljava/lang/String; + public final fun getRestoreDescription ()Ljava/lang/String; + public final fun getRestoreTitle ()Ljava/lang/String; + public final fun getTraceCopied ()Ljava/lang/String; + public final fun getTraceDebugButton ()Ljava/lang/String; + public final fun getTraceRestoreButton ()Ljava/lang/String; + public final fun getTraceStacktrace ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/skydoves/snitcher/SnitcherStrings$Companion { + public final fun defaultStrings ()Lcom/skydoves/snitcher/SnitcherStrings; +} + +public final class com/skydoves/snitcher/Snitcher_androidKt { + public static final fun install (Lcom/skydoves/snitcher/Snitcher;Landroid/app/Application;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lcom/skydoves/snitcher/SnitcherStrings;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun install$default (Lcom/skydoves/snitcher/Snitcher;Landroid/app/Application;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lcom/skydoves/snitcher/SnitcherStrings;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun installInternal (Lcom/skydoves/snitcher/Snitcher;Landroid/app/Application;Ljava/lang/String;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lcom/skydoves/snitcher/SnitcherStrings;Lkotlin/jvm/functions/Function1;)V +} + +public final class com/skydoves/snitcher/Snitcher_jvmKt { + public static final fun debug (Lcom/skydoves/snitcher/Snitcher;Lcom/skydoves/snitcher/model/SnitcherException;)Ljava/lang/Void; +} + +public final class com/skydoves/snitcher/TraceStrategy : java/lang/Enum { + public static final field CO_WORK Lcom/skydoves/snitcher/TraceStrategy; + public static final field REPLACE Lcom/skydoves/snitcher/TraceStrategy; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/skydoves/snitcher/TraceStrategy; + public static fun values ()[Lcom/skydoves/snitcher/TraceStrategy; +} + +public final class com/skydoves/snitcher/model/SnitcherException { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/model/SnitcherException$Companion; + public fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/util/List; + public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lcom/skydoves/snitcher/model/SnitcherException; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/model/SnitcherException;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/skydoves/snitcher/model/SnitcherException; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public final fun getPackageName ()Ljava/lang/String; + public final fun getStackTrace ()Ljava/lang/String; + public final fun getStackTraceElement ()Ljava/util/List; + public final fun getThreadId ()J + public final fun getThreadName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/skydoves/snitcher/model/SnitcherException$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/skydoves/snitcher/model/SnitcherException$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/skydoves/snitcher/model/SnitcherException; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/skydoves/snitcher/model/SnitcherException;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/skydoves/snitcher/model/SnitcherException$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/skydoves/snitcher/model/SnitcherExceptionsKt { + public static final fun toThrowable (Lcom/skydoves/snitcher/model/SnitcherException;)Ljava/lang/Throwable; +} + +public final class com/skydoves/snitcher/model/SnitcherStackTraceElement { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/model/SnitcherStackTraceElement$Companion; + public fun (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()I + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)Lcom/skydoves/snitcher/model/SnitcherStackTraceElement; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/model/SnitcherStackTraceElement;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/skydoves/snitcher/model/SnitcherStackTraceElement; + public fun equals (Ljava/lang/Object;)Z + public final fun getClassName ()Ljava/lang/String; + public final fun getFileName ()Ljava/lang/String; + public final fun getLineNumber ()I + public final fun getMethodName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class com/skydoves/snitcher/model/SnitcherStackTraceElement$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/skydoves/snitcher/model/SnitcherStackTraceElement$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/skydoves/snitcher/model/SnitcherStackTraceElement; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/skydoves/snitcher/model/SnitcherStackTraceElement;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/skydoves/snitcher/model/SnitcherStackTraceElement$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/skydoves/snitcher/ui/AppRestoreScreenKt { + public static final fun AppRestoreScreen (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/skydoves/snitcher/ui/ComposableSingletons$ExceptionTraceActivityKt { + public static final field INSTANCE Lcom/skydoves/snitcher/ui/ComposableSingletons$ExceptionTraceActivityKt; + public fun ()V + public final fun getLambda$-98219935$SnitcherDemo_snitcher ()Lkotlin/jvm/functions/Function2; +} + +public class com/skydoves/snitcher/ui/ExceptionTraceActivity : androidx/activity/ComponentActivity { + public static final field $stable I + public fun ()V + protected fun onCreate (Landroid/os/Bundle;)V +} + +public final class com/skydoves/snitcher/ui/ExceptionTraceScreenKt { + public static final fun ExceptionTraceScreen (Lcom/skydoves/snitcher/model/SnitcherException;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/skydoves/snitcher/ui/SnitcherScreens_androidKt { + public static final fun AppRestoreScreen (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun ExceptionTraceScreen (Ljava/lang/String;Lcom/skydoves/snitcher/model/SnitcherException;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherColor { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherColor$Companion; + public static final field UNSPECIFIED J + public synthetic fun (JJJJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-0d7_KjU ()J + public final fun component2-0d7_KjU ()J + public final fun component3-0d7_KjU ()J + public final fun component4-0d7_KjU ()J + public final fun component5-0d7_KjU ()J + public final fun component6-0d7_KjU ()J + public final fun copy-tNS2XkQ (JJJJJJ)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public static synthetic fun copy-tNS2XkQ$default (Lcom/skydoves/snitcher/ui/theme/SnitcherColor;JJJJJJILjava/lang/Object;)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public fun equals (Ljava/lang/Object;)Z + public final fun getBackground-0d7_KjU ()J + public final fun getOnPrimary-0d7_KjU ()J + public final fun getOutline-0d7_KjU ()J + public final fun getPrimary-0d7_KjU ()J + public final fun getTextHighEmphasis-0d7_KjU ()J + public final fun getTextLowEmphasis-0d7_KjU ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherColor$Companion { + public final fun defaultColors ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun defaultDarkColors ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun fromArgb (JJJJJJ)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public static synthetic fun fromArgb$default (Lcom/skydoves/snitcher/ui/theme/SnitcherColor$Companion;JJJJJJILjava/lang/Object;)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherShapes { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherShapes$Companion; + public fun ()V + public fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)V + public synthetic fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Landroidx/compose/ui/graphics/Shape; + public final fun component2 ()Landroidx/compose/ui/graphics/Shape; + public final fun copy (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;ILjava/lang/Object;)Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; + public fun equals (Ljava/lang/Object;)Z + public final fun getButton ()Landroidx/compose/ui/graphics/Shape; + public final fun getStacktrace ()Landroidx/compose/ui/graphics/Shape; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherShapes$Companion { + public final fun defaultShapes ()Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherTheme { + public static final field $stable I + public static final field INSTANCE Lcom/skydoves/snitcher/ui/theme/SnitcherTheme; + public final fun getColors (Landroidx/compose/runtime/Composer;I)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun getShapes (Landroidx/compose/runtime/Composer;I)Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; + public final fun getTypography (Landroidx/compose/runtime/Composer;I)Lcom/skydoves/snitcher/ui/theme/SnitcherTypography; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherThemeConfig { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig$Companion; + public fun ()V + public fun (Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;)V + public synthetic fun (Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun component2 ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun component3 ()Lcom/skydoves/snitcher/ui/theme/SnitcherTypography; + public final fun component4 ()Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; + public final fun copy (Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;)Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;ILjava/lang/Object;)Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getDarkColors ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun getLightColors ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun getShapes ()Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; + public final fun getTypography ()Lcom/skydoves/snitcher/ui/theme/SnitcherTypography; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherThemeConfig$Companion { + public final fun defaultThemeConfig ()Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherThemeKt { + public static final fun SnitcherTheme (ZLcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherTypography { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherTypography$Companion; + public fun (Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;)V + public final fun component1 ()Landroidx/compose/ui/text/TextStyle; + public final fun component2 ()Landroidx/compose/ui/text/TextStyle; + public final fun component3 ()Landroidx/compose/ui/text/TextStyle; + public final fun component4 ()Landroidx/compose/ui/text/TextStyle; + public final fun component5 ()Landroidx/compose/ui/text/TextStyle; + public final fun component6 ()Landroidx/compose/ui/text/TextStyle; + public final fun copy (Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;)Lcom/skydoves/snitcher/ui/theme/SnitcherTypography; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;ILjava/lang/Object;)Lcom/skydoves/snitcher/ui/theme/SnitcherTypography; + public fun equals (Ljava/lang/Object;)Z + public final fun getButton ()Landroidx/compose/ui/text/TextStyle; + public final fun getDeviceInfo ()Landroidx/compose/ui/text/TextStyle; + public final fun getMessage ()Landroidx/compose/ui/text/TextStyle; + public final fun getSectionLabel ()Landroidx/compose/ui/text/TextStyle; + public final fun getStacktrace ()Landroidx/compose/ui/text/TextStyle; + public final fun getTitle ()Landroidx/compose/ui/text/TextStyle; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/skydoves/snitcher/ui/theme/SnitcherTypography$Companion { + public final fun defaultTypography ()Lcom/skydoves/snitcher/ui/theme/SnitcherTypography; +} + diff --git a/snitcher/api/snitcher.api b/snitcher/api/desktop/snitcher.api similarity index 73% rename from snitcher/api/snitcher.api rename to snitcher/api/desktop/snitcher.api index f350aee..8329aa2 100644 --- a/snitcher/api/snitcher.api +++ b/snitcher/api/desktop/snitcher.api @@ -1,44 +1,65 @@ -public final class com/skydoves/snitcher/Snitcher : java/lang/Thread$UncaughtExceptionHandler { +public final class com/skydoves/snitcher/Snitcher { public static final field $stable I - public static final field Companion Lcom/skydoves/snitcher/Snitcher$Companion; - public fun (Landroid/app/Application;Ljava/lang/String;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Ljava/lang/Thread$UncaughtExceptionHandler;Lkotlin/jvm/functions/Function2;)V - public fun uncaughtException (Ljava/lang/Thread;Ljava/lang/Throwable;)V -} - -public final class com/skydoves/snitcher/Snitcher$Companion { + public static final field INSTANCE Lcom/skydoves/snitcher/Snitcher; + public final fun clear ()V public final fun getException ()Lkotlinx/coroutines/flow/StateFlow; - public final fun getLauncher ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getLatestException ()Lcom/skydoves/snitcher/model/SnitcherException; + public final fun getPlatformInfo ()Ljava/lang/String; + public final fun getStrings ()Lcom/skydoves/snitcher/SnitcherStrings; public final fun getTheme ()Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig; - public final fun install (Landroid/app/Application;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun install$default (Lcom/skydoves/snitcher/Snitcher$Companion;Landroid/app/Application;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public final fun isDebuggable ()Z - public final fun isDebuggableApp (Landroid/app/Application;)Z public final fun isInstalled ()Z public final fun setDebuggable (Z)V + public final fun setPlatformInfo (Ljava/lang/String;)V + public final fun setStrings (Lcom/skydoves/snitcher/SnitcherStrings;)V public final fun setTheme (Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;)V } -public final class com/skydoves/snitcher/TraceStrategy : java/lang/Enum { - public static final field CO_WORK Lcom/skydoves/snitcher/TraceStrategy; - public static final field REPLACE Lcom/skydoves/snitcher/TraceStrategy; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/skydoves/snitcher/TraceStrategy; - public static fun values ()[Lcom/skydoves/snitcher/TraceStrategy; +public final class com/skydoves/snitcher/SnitcherStrings { + public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/SnitcherStrings$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/skydoves/snitcher/SnitcherStrings; + public static synthetic fun copy$default (Lcom/skydoves/snitcher/SnitcherStrings;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/skydoves/snitcher/SnitcherStrings; + public fun equals (Ljava/lang/Object;)Z + public final fun getRestoreButton ()Ljava/lang/String; + public final fun getRestoreDescription ()Ljava/lang/String; + public final fun getRestoreTitle ()Ljava/lang/String; + public final fun getTraceCopied ()Ljava/lang/String; + public final fun getTraceDebugButton ()Ljava/lang/String; + public final fun getTraceRestoreButton ()Ljava/lang/String; + public final fun getTraceStacktrace ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } -public final class com/skydoves/snitcher/installer/SnitcherInstaller { - public static final field $stable I - public static final field INSTANCE Lcom/skydoves/snitcher/installer/SnitcherInstaller; - public final fun getInternalSnitcher ()Lkotlinx/coroutines/flow/MutableStateFlow; - public static final fun install (Lcom/skydoves/snitcher/Snitcher;)V - public static final fun isInstalled ()Z - public final fun setInternalSnitcher (Lkotlinx/coroutines/flow/MutableStateFlow;)V +public final class com/skydoves/snitcher/SnitcherStrings$Companion { + public final fun defaultStrings ()Lcom/skydoves/snitcher/SnitcherStrings; +} + +public final class com/skydoves/snitcher/Snitcher_desktopKt { + public static final fun install (Lcom/skydoves/snitcher/Snitcher;Ljava/lang/String;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lcom/skydoves/snitcher/SnitcherStrings;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V + public static synthetic fun install$default (Lcom/skydoves/snitcher/Snitcher;Ljava/lang/String;Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig;Lcom/skydoves/snitcher/SnitcherStrings;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } -public final class com/skydoves/snitcher/model/SnitcherException : java/io/Serializable { +public final class com/skydoves/snitcher/Snitcher_jvmKt { + public static final fun debug (Lcom/skydoves/snitcher/Snitcher;Lcom/skydoves/snitcher/model/SnitcherException;)Ljava/lang/Void; +} + +public final class com/skydoves/snitcher/model/SnitcherException { public static final field $stable I public static final field Companion Lcom/skydoves/snitcher/model/SnitcherException$Companion; public fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; @@ -54,7 +75,6 @@ public final class com/skydoves/snitcher/model/SnitcherException : java/io/Seria public final fun getStackTraceElement ()Ljava/util/List; public final fun getThreadId ()J public final fun getThreadName ()Ljava/lang/String; - public final fun getThrowable ()Ljava/lang/Throwable; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -75,6 +95,10 @@ public final class com/skydoves/snitcher/model/SnitcherException$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class com/skydoves/snitcher/model/SnitcherExceptionsKt { + public static final fun toThrowable (Lcom/skydoves/snitcher/model/SnitcherException;)Ljava/lang/Throwable; +} + public final class com/skydoves/snitcher/model/SnitcherStackTraceElement { public static final field $stable I public static final field Companion Lcom/skydoves/snitcher/model/SnitcherStackTraceElement$Companion; @@ -111,28 +135,21 @@ public final class com/skydoves/snitcher/model/SnitcherStackTraceElement$Compani } public final class com/skydoves/snitcher/ui/AppRestoreScreenKt { - public static final fun AppRestoreScreen (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V + public static final fun AppRestoreScreen (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V } -public final class com/skydoves/snitcher/ui/ComposableSingletons$ExceptionTraceActivityKt { - public static final field INSTANCE Lcom/skydoves/snitcher/ui/ComposableSingletons$ExceptionTraceActivityKt; - public fun ()V - public final fun getLambda$-98219935$SnitcherDemo_snitcher_release ()Lkotlin/jvm/functions/Function2; -} - -public class com/skydoves/snitcher/ui/ExceptionTraceActivity : androidx/activity/ComponentActivity { - public static final field $stable I - public fun ()V - protected fun onCreate (Landroid/os/Bundle;)V +public final class com/skydoves/snitcher/ui/ExceptionTraceScreenKt { + public static final fun ExceptionTraceScreen (Lcom/skydoves/snitcher/model/SnitcherException;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V } -public final class com/skydoves/snitcher/ui/ExceptionTraceScreenKt { - public static final fun ExceptionTraceScreen (Ljava/lang/String;Lcom/skydoves/snitcher/model/SnitcherException;Landroidx/compose/runtime/Composer;I)V +public final class com/skydoves/snitcher/ui/SnitcherTraceWindowKt { + public static final fun SnitcherTraceWindow (Landroidx/compose/ui/window/ApplicationScope;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V } public final class com/skydoves/snitcher/ui/theme/SnitcherColor { public static final field $stable I public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherColor$Companion; + public static final field UNSPECIFIED J public synthetic fun (JJJJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (JJJJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-0d7_KjU ()J @@ -157,10 +174,13 @@ public final class com/skydoves/snitcher/ui/theme/SnitcherColor { public final class com/skydoves/snitcher/ui/theme/SnitcherColor$Companion { public final fun defaultColors ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; public final fun defaultDarkColors ()Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public final fun fromArgb (JJJJJJ)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; + public static synthetic fun fromArgb$default (Lcom/skydoves/snitcher/ui/theme/SnitcherColor$Companion;JJJJJJILjava/lang/Object;)Lcom/skydoves/snitcher/ui/theme/SnitcherColor; } public final class com/skydoves/snitcher/ui/theme/SnitcherShapes { public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherShapes$Companion; public fun ()V public fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)V public synthetic fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -175,6 +195,10 @@ public final class com/skydoves/snitcher/ui/theme/SnitcherShapes { public fun toString ()Ljava/lang/String; } +public final class com/skydoves/snitcher/ui/theme/SnitcherShapes$Companion { + public final fun defaultShapes ()Lcom/skydoves/snitcher/ui/theme/SnitcherShapes; +} + public final class com/skydoves/snitcher/ui/theme/SnitcherTheme { public static final field $stable I public static final field INSTANCE Lcom/skydoves/snitcher/ui/theme/SnitcherTheme; @@ -185,6 +209,7 @@ public final class com/skydoves/snitcher/ui/theme/SnitcherTheme { public final class com/skydoves/snitcher/ui/theme/SnitcherThemeConfig { public static final field $stable I + public static final field Companion Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig$Companion; public fun ()V public fun (Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;)V public synthetic fun (Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -203,6 +228,10 @@ public final class com/skydoves/snitcher/ui/theme/SnitcherThemeConfig { public fun toString ()Ljava/lang/String; } +public final class com/skydoves/snitcher/ui/theme/SnitcherThemeConfig$Companion { + public final fun defaultThemeConfig ()Lcom/skydoves/snitcher/ui/theme/SnitcherThemeConfig; +} + public final class com/skydoves/snitcher/ui/theme/SnitcherThemeKt { public static final fun SnitcherTheme (ZLcom/skydoves/snitcher/ui/theme/SnitcherColor;Lcom/skydoves/snitcher/ui/theme/SnitcherTypography;Lcom/skydoves/snitcher/ui/theme/SnitcherShapes;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } diff --git a/snitcher/api/snitcher.klib.api b/snitcher/api/snitcher.klib.api new file mode 100644 index 0000000..ef2a6e5 --- /dev/null +++ b/snitcher/api/snitcher.klib.api @@ -0,0 +1,300 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class com.skydoves.snitcher.model/SnitcherException { // com.skydoves.snitcher.model/SnitcherException|null[0] + constructor (kotlin/Long, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin.collections/List = ...) // com.skydoves.snitcher.model/SnitcherException.|(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.collections.List){}[0] + + final val message // com.skydoves.snitcher.model/SnitcherException.message|{}message[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherException.message.|(){}[0] + final val packageName // com.skydoves.snitcher.model/SnitcherException.packageName|{}packageName[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherException.packageName.|(){}[0] + final val stackTrace // com.skydoves.snitcher.model/SnitcherException.stackTrace|{}stackTrace[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherException.stackTrace.|(){}[0] + final val stackTraceElement // com.skydoves.snitcher.model/SnitcherException.stackTraceElement|{}stackTraceElement[0] + final fun (): kotlin.collections/List // com.skydoves.snitcher.model/SnitcherException.stackTraceElement.|(){}[0] + final val threadId // com.skydoves.snitcher.model/SnitcherException.threadId|{}threadId[0] + final fun (): kotlin/Long // com.skydoves.snitcher.model/SnitcherException.threadId.|(){}[0] + final val threadName // com.skydoves.snitcher.model/SnitcherException.threadName|{}threadName[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherException.threadName.|(){}[0] + + final fun component1(): kotlin/Long // com.skydoves.snitcher.model/SnitcherException.component1|component1(){}[0] + final fun component2(): kotlin/String // com.skydoves.snitcher.model/SnitcherException.component2|component2(){}[0] + final fun component3(): kotlin/String // com.skydoves.snitcher.model/SnitcherException.component3|component3(){}[0] + final fun component4(): kotlin/String // com.skydoves.snitcher.model/SnitcherException.component4|component4(){}[0] + final fun component5(): kotlin/String // com.skydoves.snitcher.model/SnitcherException.component5|component5(){}[0] + final fun component6(): kotlin.collections/List // com.skydoves.snitcher.model/SnitcherException.component6|component6(){}[0] + final fun copy(kotlin/Long = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin.collections/List = ...): com.skydoves.snitcher.model/SnitcherException // com.skydoves.snitcher.model/SnitcherException.copy|copy(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher.model/SnitcherException.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher.model/SnitcherException.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher.model/SnitcherException.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.skydoves.snitcher.model/SnitcherException.$serializer|null[0] + final val descriptor // com.skydoves.snitcher.model/SnitcherException.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.skydoves.snitcher.model/SnitcherException.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.skydoves.snitcher.model/SnitcherException.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.skydoves.snitcher.model/SnitcherException // com.skydoves.snitcher.model/SnitcherException.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.skydoves.snitcher.model/SnitcherException) // com.skydoves.snitcher.model/SnitcherException.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.skydoves.snitcher.model.SnitcherException){}[0] + } + + final object Companion { // com.skydoves.snitcher.model/SnitcherException.Companion|null[0] + final val $childSerializers // com.skydoves.snitcher.model/SnitcherException.Companion.$childSerializers|{}$childSerializers[0] + + final fun serializer(): kotlinx.serialization/KSerializer // com.skydoves.snitcher.model/SnitcherException.Companion.serializer|serializer(){}[0] + } +} + +final class com.skydoves.snitcher.model/SnitcherStackTraceElement { // com.skydoves.snitcher.model/SnitcherStackTraceElement|null[0] + constructor (kotlin/String, kotlin/Int, kotlin/String, kotlin/String) // com.skydoves.snitcher.model/SnitcherStackTraceElement.|(kotlin.String;kotlin.Int;kotlin.String;kotlin.String){}[0] + + final val className // com.skydoves.snitcher.model/SnitcherStackTraceElement.className|{}className[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.className.|(){}[0] + final val fileName // com.skydoves.snitcher.model/SnitcherStackTraceElement.fileName|{}fileName[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.fileName.|(){}[0] + final val lineNumber // com.skydoves.snitcher.model/SnitcherStackTraceElement.lineNumber|{}lineNumber[0] + final fun (): kotlin/Int // com.skydoves.snitcher.model/SnitcherStackTraceElement.lineNumber.|(){}[0] + final val methodName // com.skydoves.snitcher.model/SnitcherStackTraceElement.methodName|{}methodName[0] + final fun (): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.methodName.|(){}[0] + + final fun component1(): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.component1|component1(){}[0] + final fun component2(): kotlin/Int // com.skydoves.snitcher.model/SnitcherStackTraceElement.component2|component2(){}[0] + final fun component3(): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.component3|component3(){}[0] + final fun component4(): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.component4|component4(){}[0] + final fun copy(kotlin/String = ..., kotlin/Int = ..., kotlin/String = ..., kotlin/String = ...): com.skydoves.snitcher.model/SnitcherStackTraceElement // com.skydoves.snitcher.model/SnitcherStackTraceElement.copy|copy(kotlin.String;kotlin.Int;kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher.model/SnitcherStackTraceElement.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher.model/SnitcherStackTraceElement.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher.model/SnitcherStackTraceElement.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // com.skydoves.snitcher.model/SnitcherStackTraceElement.$serializer|null[0] + final val descriptor // com.skydoves.snitcher.model/SnitcherStackTraceElement.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.skydoves.snitcher.model/SnitcherStackTraceElement.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // com.skydoves.snitcher.model/SnitcherStackTraceElement.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.skydoves.snitcher.model/SnitcherStackTraceElement // com.skydoves.snitcher.model/SnitcherStackTraceElement.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.skydoves.snitcher.model/SnitcherStackTraceElement) // com.skydoves.snitcher.model/SnitcherStackTraceElement.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.skydoves.snitcher.model.SnitcherStackTraceElement){}[0] + } + + final object Companion { // com.skydoves.snitcher.model/SnitcherStackTraceElement.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // com.skydoves.snitcher.model/SnitcherStackTraceElement.Companion.serializer|serializer(){}[0] + } +} + +final class com.skydoves.snitcher.ui.theme/SnitcherColor { // com.skydoves.snitcher.ui.theme/SnitcherColor|null[0] + constructor (androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ...) // com.skydoves.snitcher.ui.theme/SnitcherColor.|(androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color){}[0] + + final val background // com.skydoves.snitcher.ui.theme/SnitcherColor.background|{}background[0] + final fun (): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.background.|(){}[0] + final val onPrimary // com.skydoves.snitcher.ui.theme/SnitcherColor.onPrimary|{}onPrimary[0] + final fun (): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.onPrimary.|(){}[0] + final val outline // com.skydoves.snitcher.ui.theme/SnitcherColor.outline|{}outline[0] + final fun (): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.outline.|(){}[0] + final val primary // com.skydoves.snitcher.ui.theme/SnitcherColor.primary|{}primary[0] + final fun (): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.primary.|(){}[0] + final val textHighEmphasis // com.skydoves.snitcher.ui.theme/SnitcherColor.textHighEmphasis|{}textHighEmphasis[0] + final fun (): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.textHighEmphasis.|(){}[0] + final val textLowEmphasis // com.skydoves.snitcher.ui.theme/SnitcherColor.textLowEmphasis|{}textLowEmphasis[0] + final fun (): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.textLowEmphasis.|(){}[0] + + final fun component1(): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.component1|component1(){}[0] + final fun component2(): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.component2|component2(){}[0] + final fun component3(): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.component3|component3(){}[0] + final fun component4(): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.component4|component4(){}[0] + final fun component5(): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.component5|component5(){}[0] + final fun component6(): androidx.compose.ui.graphics/Color // com.skydoves.snitcher.ui.theme/SnitcherColor.component6|component6(){}[0] + final fun copy(androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ..., androidx.compose.ui.graphics/Color = ...): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherColor.copy|copy(androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher.ui.theme/SnitcherColor.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher.ui.theme/SnitcherColor.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher.ui.theme/SnitcherColor.toString|toString(){}[0] + + final object Companion { // com.skydoves.snitcher.ui.theme/SnitcherColor.Companion|null[0] + final const val UNSPECIFIED // com.skydoves.snitcher.ui.theme/SnitcherColor.Companion.UNSPECIFIED|{}UNSPECIFIED[0] + final fun (): kotlin/Long // com.skydoves.snitcher.ui.theme/SnitcherColor.Companion.UNSPECIFIED.|(){}[0] + + final fun defaultColors(): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherColor.Companion.defaultColors|defaultColors(){}[0] + final fun defaultDarkColors(): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherColor.Companion.defaultDarkColors|defaultDarkColors(){}[0] + final fun fromArgb(kotlin/Long, kotlin/Long, kotlin/Long, kotlin/Long = ..., kotlin/Long = ..., kotlin/Long = ...): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherColor.Companion.fromArgb|fromArgb(kotlin.Long;kotlin.Long;kotlin.Long;kotlin.Long;kotlin.Long;kotlin.Long){}[0] + } +} + +final class com.skydoves.snitcher.ui.theme/SnitcherShapes { // com.skydoves.snitcher.ui.theme/SnitcherShapes|null[0] + constructor (androidx.compose.ui.graphics/Shape = ..., androidx.compose.ui.graphics/Shape = ...) // com.skydoves.snitcher.ui.theme/SnitcherShapes.|(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape){}[0] + + final val button // com.skydoves.snitcher.ui.theme/SnitcherShapes.button|{}button[0] + final fun (): androidx.compose.ui.graphics/Shape // com.skydoves.snitcher.ui.theme/SnitcherShapes.button.|(){}[0] + final val stacktrace // com.skydoves.snitcher.ui.theme/SnitcherShapes.stacktrace|{}stacktrace[0] + final fun (): androidx.compose.ui.graphics/Shape // com.skydoves.snitcher.ui.theme/SnitcherShapes.stacktrace.|(){}[0] + + final fun component1(): androidx.compose.ui.graphics/Shape // com.skydoves.snitcher.ui.theme/SnitcherShapes.component1|component1(){}[0] + final fun component2(): androidx.compose.ui.graphics/Shape // com.skydoves.snitcher.ui.theme/SnitcherShapes.component2|component2(){}[0] + final fun copy(androidx.compose.ui.graphics/Shape = ..., androidx.compose.ui.graphics/Shape = ...): com.skydoves.snitcher.ui.theme/SnitcherShapes // com.skydoves.snitcher.ui.theme/SnitcherShapes.copy|copy(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher.ui.theme/SnitcherShapes.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher.ui.theme/SnitcherShapes.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher.ui.theme/SnitcherShapes.toString|toString(){}[0] + + final object Companion { // com.skydoves.snitcher.ui.theme/SnitcherShapes.Companion|null[0] + final fun defaultShapes(): com.skydoves.snitcher.ui.theme/SnitcherShapes // com.skydoves.snitcher.ui.theme/SnitcherShapes.Companion.defaultShapes|defaultShapes(){}[0] + } +} + +final class com.skydoves.snitcher.ui.theme/SnitcherThemeConfig { // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig|null[0] + constructor (com.skydoves.snitcher.ui.theme/SnitcherColor = ..., com.skydoves.snitcher.ui.theme/SnitcherColor = ..., com.skydoves.snitcher.ui.theme/SnitcherTypography = ..., com.skydoves.snitcher.ui.theme/SnitcherShapes = ...) // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.|(com.skydoves.snitcher.ui.theme.SnitcherColor;com.skydoves.snitcher.ui.theme.SnitcherColor;com.skydoves.snitcher.ui.theme.SnitcherTypography;com.skydoves.snitcher.ui.theme.SnitcherShapes){}[0] + + final val darkColors // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.darkColors|{}darkColors[0] + final fun (): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.darkColors.|(){}[0] + final val lightColors // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.lightColors|{}lightColors[0] + final fun (): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.lightColors.|(){}[0] + final val shapes // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.shapes|{}shapes[0] + final fun (): com.skydoves.snitcher.ui.theme/SnitcherShapes // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.shapes.|(){}[0] + final val typography // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.typography|{}typography[0] + final fun (): com.skydoves.snitcher.ui.theme/SnitcherTypography // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.typography.|(){}[0] + + final fun component1(): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.component1|component1(){}[0] + final fun component2(): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.component2|component2(){}[0] + final fun component3(): com.skydoves.snitcher.ui.theme/SnitcherTypography // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.component3|component3(){}[0] + final fun component4(): com.skydoves.snitcher.ui.theme/SnitcherShapes // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.component4|component4(){}[0] + final fun copy(com.skydoves.snitcher.ui.theme/SnitcherColor = ..., com.skydoves.snitcher.ui.theme/SnitcherColor = ..., com.skydoves.snitcher.ui.theme/SnitcherTypography = ..., com.skydoves.snitcher.ui.theme/SnitcherShapes = ...): com.skydoves.snitcher.ui.theme/SnitcherThemeConfig // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.copy|copy(com.skydoves.snitcher.ui.theme.SnitcherColor;com.skydoves.snitcher.ui.theme.SnitcherColor;com.skydoves.snitcher.ui.theme.SnitcherTypography;com.skydoves.snitcher.ui.theme.SnitcherShapes){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.toString|toString(){}[0] + + final object Companion { // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.Companion|null[0] + final fun defaultThemeConfig(): com.skydoves.snitcher.ui.theme/SnitcherThemeConfig // com.skydoves.snitcher.ui.theme/SnitcherThemeConfig.Companion.defaultThemeConfig|defaultThemeConfig(){}[0] + } +} + +final class com.skydoves.snitcher.ui.theme/SnitcherTypography { // com.skydoves.snitcher.ui.theme/SnitcherTypography|null[0] + constructor (androidx.compose.ui.text/TextStyle, androidx.compose.ui.text/TextStyle, androidx.compose.ui.text/TextStyle, androidx.compose.ui.text/TextStyle, androidx.compose.ui.text/TextStyle, androidx.compose.ui.text/TextStyle) // com.skydoves.snitcher.ui.theme/SnitcherTypography.|(androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle){}[0] + + final val button // com.skydoves.snitcher.ui.theme/SnitcherTypography.button|{}button[0] + final fun (): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.button.|(){}[0] + final val deviceInfo // com.skydoves.snitcher.ui.theme/SnitcherTypography.deviceInfo|{}deviceInfo[0] + final fun (): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.deviceInfo.|(){}[0] + final val message // com.skydoves.snitcher.ui.theme/SnitcherTypography.message|{}message[0] + final fun (): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.message.|(){}[0] + final val sectionLabel // com.skydoves.snitcher.ui.theme/SnitcherTypography.sectionLabel|{}sectionLabel[0] + final fun (): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.sectionLabel.|(){}[0] + final val stacktrace // com.skydoves.snitcher.ui.theme/SnitcherTypography.stacktrace|{}stacktrace[0] + final fun (): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.stacktrace.|(){}[0] + final val title // com.skydoves.snitcher.ui.theme/SnitcherTypography.title|{}title[0] + final fun (): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.title.|(){}[0] + + final fun component1(): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.component1|component1(){}[0] + final fun component2(): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.component2|component2(){}[0] + final fun component3(): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.component3|component3(){}[0] + final fun component4(): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.component4|component4(){}[0] + final fun component5(): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.component5|component5(){}[0] + final fun component6(): androidx.compose.ui.text/TextStyle // com.skydoves.snitcher.ui.theme/SnitcherTypography.component6|component6(){}[0] + final fun copy(androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ..., androidx.compose.ui.text/TextStyle = ...): com.skydoves.snitcher.ui.theme/SnitcherTypography // com.skydoves.snitcher.ui.theme/SnitcherTypography.copy|copy(androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle;androidx.compose.ui.text.TextStyle){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher.ui.theme/SnitcherTypography.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher.ui.theme/SnitcherTypography.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher.ui.theme/SnitcherTypography.toString|toString(){}[0] + + final object Companion { // com.skydoves.snitcher.ui.theme/SnitcherTypography.Companion|null[0] + final fun defaultTypography(): com.skydoves.snitcher.ui.theme/SnitcherTypography // com.skydoves.snitcher.ui.theme/SnitcherTypography.Companion.defaultTypography|defaultTypography(){}[0] + } +} + +final class com.skydoves.snitcher/SnitcherStrings { // com.skydoves.snitcher/SnitcherStrings|null[0] + constructor (kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ...) // com.skydoves.snitcher/SnitcherStrings.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String){}[0] + + final val restoreButton // com.skydoves.snitcher/SnitcherStrings.restoreButton|{}restoreButton[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.restoreButton.|(){}[0] + final val restoreDescription // com.skydoves.snitcher/SnitcherStrings.restoreDescription|{}restoreDescription[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.restoreDescription.|(){}[0] + final val restoreTitle // com.skydoves.snitcher/SnitcherStrings.restoreTitle|{}restoreTitle[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.restoreTitle.|(){}[0] + final val traceCopied // com.skydoves.snitcher/SnitcherStrings.traceCopied|{}traceCopied[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.traceCopied.|(){}[0] + final val traceDebugButton // com.skydoves.snitcher/SnitcherStrings.traceDebugButton|{}traceDebugButton[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.traceDebugButton.|(){}[0] + final val traceRestoreButton // com.skydoves.snitcher/SnitcherStrings.traceRestoreButton|{}traceRestoreButton[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.traceRestoreButton.|(){}[0] + final val traceStacktrace // com.skydoves.snitcher/SnitcherStrings.traceStacktrace|{}traceStacktrace[0] + final fun (): kotlin/String // com.skydoves.snitcher/SnitcherStrings.traceStacktrace.|(){}[0] + + final fun component1(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component1|component1(){}[0] + final fun component2(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component2|component2(){}[0] + final fun component3(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component3|component3(){}[0] + final fun component4(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component4|component4(){}[0] + final fun component5(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component5|component5(){}[0] + final fun component6(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component6|component6(){}[0] + final fun component7(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.component7|component7(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ...): com.skydoves.snitcher/SnitcherStrings // com.skydoves.snitcher/SnitcherStrings.copy|copy(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.skydoves.snitcher/SnitcherStrings.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.skydoves.snitcher/SnitcherStrings.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.skydoves.snitcher/SnitcherStrings.toString|toString(){}[0] + + final object Companion { // com.skydoves.snitcher/SnitcherStrings.Companion|null[0] + final fun defaultStrings(): com.skydoves.snitcher/SnitcherStrings // com.skydoves.snitcher/SnitcherStrings.Companion.defaultStrings|defaultStrings(){}[0] + } +} + +final object com.skydoves.snitcher.ui.theme/SnitcherTheme { // com.skydoves.snitcher.ui.theme/SnitcherTheme|null[0] + final val colors // com.skydoves.snitcher.ui.theme/SnitcherTheme.colors|{}colors[0] + final fun (androidx.compose.runtime/Composer?, kotlin/Int): com.skydoves.snitcher.ui.theme/SnitcherColor // com.skydoves.snitcher.ui.theme/SnitcherTheme.colors.|(androidx.compose.runtime.Composer?;kotlin.Int){}[0] + final val shapes // com.skydoves.snitcher.ui.theme/SnitcherTheme.shapes|{}shapes[0] + final fun (androidx.compose.runtime/Composer?, kotlin/Int): com.skydoves.snitcher.ui.theme/SnitcherShapes // com.skydoves.snitcher.ui.theme/SnitcherTheme.shapes.|(androidx.compose.runtime.Composer?;kotlin.Int){}[0] + final val typography // com.skydoves.snitcher.ui.theme/SnitcherTheme.typography|{}typography[0] + final fun (androidx.compose.runtime/Composer?, kotlin/Int): com.skydoves.snitcher.ui.theme/SnitcherTypography // com.skydoves.snitcher.ui.theme/SnitcherTheme.typography.|(androidx.compose.runtime.Composer?;kotlin.Int){}[0] +} + +final object com.skydoves.snitcher/Snitcher { // com.skydoves.snitcher/Snitcher|null[0] + final val exception // com.skydoves.snitcher/Snitcher.exception|{}exception[0] + final fun (): kotlinx.coroutines.flow/StateFlow // com.skydoves.snitcher/Snitcher.exception.|(){}[0] + final val isInstalled // com.skydoves.snitcher/Snitcher.isInstalled|{}isInstalled[0] + final fun (): kotlin/Boolean // com.skydoves.snitcher/Snitcher.isInstalled.|(){}[0] + final val latestException // com.skydoves.snitcher/Snitcher.latestException|{}latestException[0] + final fun (): com.skydoves.snitcher.model/SnitcherException? // com.skydoves.snitcher/Snitcher.latestException.|(){}[0] + + final var isDebuggable // com.skydoves.snitcher/Snitcher.isDebuggable|{}isDebuggable[0] + final fun (): kotlin/Boolean // com.skydoves.snitcher/Snitcher.isDebuggable.|(){}[0] + final fun (kotlin/Boolean) // com.skydoves.snitcher/Snitcher.isDebuggable.|(kotlin.Boolean){}[0] + final var platformInfo // com.skydoves.snitcher/Snitcher.platformInfo|{}platformInfo[0] + final fun (): kotlin/String // com.skydoves.snitcher/Snitcher.platformInfo.|(){}[0] + final fun (kotlin/String) // com.skydoves.snitcher/Snitcher.platformInfo.|(kotlin.String){}[0] + final var strings // com.skydoves.snitcher/Snitcher.strings|{}strings[0] + final fun (): com.skydoves.snitcher/SnitcherStrings // com.skydoves.snitcher/Snitcher.strings.|(){}[0] + final fun (com.skydoves.snitcher/SnitcherStrings) // com.skydoves.snitcher/Snitcher.strings.|(com.skydoves.snitcher.SnitcherStrings){}[0] + final var theme // com.skydoves.snitcher/Snitcher.theme|{}theme[0] + final fun (): com.skydoves.snitcher.ui.theme/SnitcherThemeConfig // com.skydoves.snitcher/Snitcher.theme.|(){}[0] + final fun (com.skydoves.snitcher.ui.theme/SnitcherThemeConfig) // com.skydoves.snitcher/Snitcher.theme.|(com.skydoves.snitcher.ui.theme.SnitcherThemeConfig){}[0] + + final fun clear() // com.skydoves.snitcher/Snitcher.clear|clear(){}[0] +} + +final val com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException$stableprop // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException$stableprop|#static{}com_skydoves_snitcher_model_SnitcherException$stableprop[0] +final val com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException_$serializer$stableprop // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException_$serializer$stableprop|#static{}com_skydoves_snitcher_model_SnitcherException_$serializer$stableprop[0] +final val com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherPreference_$serializer$stableprop // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherPreference_$serializer$stableprop|#static{}com_skydoves_snitcher_model_SnitcherPreference_$serializer$stableprop[0] +final val com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement$stableprop // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement$stableprop|#static{}com_skydoves_snitcher_model_SnitcherStackTraceElement$stableprop[0] +final val com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement_$serializer$stableprop // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement_$serializer$stableprop|#static{}com_skydoves_snitcher_model_SnitcherStackTraceElement_$serializer$stableprop[0] +final val com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherColor$stableprop // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherColor$stableprop|#static{}com_skydoves_snitcher_ui_theme_SnitcherColor$stableprop[0] +final val com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherShapes$stableprop // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherShapes$stableprop|#static{}com_skydoves_snitcher_ui_theme_SnitcherShapes$stableprop[0] +final val com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTheme$stableprop // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTheme$stableprop|#static{}com_skydoves_snitcher_ui_theme_SnitcherTheme$stableprop[0] +final val com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherThemeConfig$stableprop // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherThemeConfig$stableprop|#static{}com_skydoves_snitcher_ui_theme_SnitcherThemeConfig$stableprop[0] +final val com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTypography$stableprop // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTypography$stableprop|#static{}com_skydoves_snitcher_ui_theme_SnitcherTypography$stableprop[0] +final val com.skydoves.snitcher/com_skydoves_snitcher_Snitcher$stableprop // com.skydoves.snitcher/com_skydoves_snitcher_Snitcher$stableprop|#static{}com_skydoves_snitcher_Snitcher$stableprop[0] +final val com.skydoves.snitcher/com_skydoves_snitcher_SnitcherStrings$stableprop // com.skydoves.snitcher/com_skydoves_snitcher_SnitcherStrings$stableprop|#static{}com_skydoves_snitcher_SnitcherStrings$stableprop[0] + +final fun (com.skydoves.snitcher/Snitcher).com.skydoves.snitcher/install() // com.skydoves.snitcher/install|install@com.skydoves.snitcher.Snitcher(){}[0] +final fun (com.skydoves.snitcher/Snitcher).com.skydoves.snitcher/install(kotlin/String = ..., com.skydoves.snitcher.ui.theme/SnitcherThemeConfig = ..., com.skydoves.snitcher/SnitcherStrings = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Function1 = ...) // com.skydoves.snitcher/install|install@com.skydoves.snitcher.Snitcher(kotlin.String;com.skydoves.snitcher.ui.theme.SnitcherThemeConfig;com.skydoves.snitcher.SnitcherStrings;kotlin.String;kotlin.Boolean;kotlin.Function1){}[0] +final fun com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException$stableprop_getter|com_skydoves_snitcher_model_SnitcherException$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException_$serializer$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherException_$serializer$stableprop_getter|com_skydoves_snitcher_model_SnitcherException_$serializer$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherPreference_$serializer$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherPreference_$serializer$stableprop_getter|com_skydoves_snitcher_model_SnitcherPreference_$serializer$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement$stableprop_getter|com_skydoves_snitcher_model_SnitcherStackTraceElement$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement_$serializer$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.model/com_skydoves_snitcher_model_SnitcherStackTraceElement_$serializer$stableprop_getter|com_skydoves_snitcher_model_SnitcherStackTraceElement_$serializer$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.ui.theme/SnitcherTheme(kotlin/Boolean, com.skydoves.snitcher.ui.theme/SnitcherColor?, com.skydoves.snitcher.ui.theme/SnitcherTypography?, com.skydoves.snitcher.ui.theme/SnitcherShapes?, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.skydoves.snitcher.ui.theme/SnitcherTheme|SnitcherTheme(kotlin.Boolean;com.skydoves.snitcher.ui.theme.SnitcherColor?;com.skydoves.snitcher.ui.theme.SnitcherTypography?;com.skydoves.snitcher.ui.theme.SnitcherShapes?;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherColor$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherColor$stableprop_getter|com_skydoves_snitcher_ui_theme_SnitcherColor$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherShapes$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherShapes$stableprop_getter|com_skydoves_snitcher_ui_theme_SnitcherShapes$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTheme$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTheme$stableprop_getter|com_skydoves_snitcher_ui_theme_SnitcherTheme$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherThemeConfig$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherThemeConfig$stableprop_getter|com_skydoves_snitcher_ui_theme_SnitcherThemeConfig$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTypography$stableprop_getter(): kotlin/Int // com.skydoves.snitcher.ui.theme/com_skydoves_snitcher_ui_theme_SnitcherTypography$stableprop_getter|com_skydoves_snitcher_ui_theme_SnitcherTypography$stableprop_getter(){}[0] +final fun com.skydoves.snitcher.ui/AppRestoreScreen(androidx.compose.ui/Modifier?, kotlin/Function0?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.skydoves.snitcher.ui/AppRestoreScreen|AppRestoreScreen(androidx.compose.ui.Modifier?;kotlin.Function0?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.skydoves.snitcher.ui/ExceptionTraceScreen(com.skydoves.snitcher.model/SnitcherException, androidx.compose.ui/Modifier?, kotlin/Function0?, kotlin/Function0?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.skydoves.snitcher.ui/ExceptionTraceScreen|ExceptionTraceScreen(com.skydoves.snitcher.model.SnitcherException;androidx.compose.ui.Modifier?;kotlin.Function0?;kotlin.Function0?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun com.skydoves.snitcher.ui/snitcherViewController(kotlin/Function0? = ...): platform.UIKit/UIViewController // com.skydoves.snitcher.ui/snitcherViewController|snitcherViewController(kotlin.Function0?){}[0] +final fun com.skydoves.snitcher/com_skydoves_snitcher_Snitcher$stableprop_getter(): kotlin/Int // com.skydoves.snitcher/com_skydoves_snitcher_Snitcher$stableprop_getter|com_skydoves_snitcher_Snitcher$stableprop_getter(){}[0] +final fun com.skydoves.snitcher/com_skydoves_snitcher_SnitcherStrings$stableprop_getter(): kotlin/Int // com.skydoves.snitcher/com_skydoves_snitcher_SnitcherStrings$stableprop_getter|com_skydoves_snitcher_SnitcherStrings$stableprop_getter(){}[0] diff --git a/snitcher/build.gradle.kts b/snitcher/build.gradle.kts index 7c5d4ad..227d2a8 100644 --- a/snitcher/build.gradle.kts +++ b/snitcher/build.gradle.kts @@ -1,33 +1,40 @@ -// Designed and developed by 2023 skydoves (Jaewoong Eum) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import com.skydoves.snitcher.Configuration +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.KotlinMultiplatform import org.jetbrains.kotlin.gradle.dsl.JvmTarget -@Suppress("DSL_SCOPE_VIOLATION") plugins { - id(libs.plugins.android.library.get().pluginId) - id(libs.plugins.kotlin.android.get().pluginId) - id(libs.plugins.kotlin.serialization.get().pluginId) + id(libs.plugins.kotlin.multiplatform.get().pluginId) + id(libs.plugins.android.kotlin.multiplatform.library.get().pluginId) + id(libs.plugins.compose.multiplatform.get().pluginId) id(libs.plugins.compose.compiler.get().pluginId) + id(libs.plugins.kotlin.serialization.get().pluginId) id(libs.plugins.nexus.plugin.get().pluginId) id(libs.plugins.baseline.profile.get().pluginId) + id(libs.plugins.dokka.get().pluginId) } apply(from = "${rootDir}/scripts/publish-module.gradle.kts") mavenPublishing { + configure(KotlinMultiplatform(javadocJar = JavadocJar.Dokka("dokkaGeneratePublicationHtml"))) + val artifactId = "snitcher" coordinates( Configuration.artifactGroup, @@ -41,39 +48,78 @@ mavenPublishing { } } -android { - compileSdk = Configuration.compileSdk - namespace = "com.skydoves.snitcher" - defaultConfig { +kotlin { + explicitApi() + + @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class) + abiValidation { + referenceDumpDir.set(layout.projectDirectory.dir("api")) + } + + android { + namespace = "com.skydoves.snitcher" + compileSdk = Configuration.compileSdk minSdk = Configuration.minSdk + + lint { + abortOnError = false + } + + withHostTest {} + + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get())) + } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + jvm("desktop") { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get())) + } } - buildFeatures { - compose = true + // Compose Multiplatform no longer publishes the intel simulator target. + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = "Snitcher" + isStatic = true + } } - resourcePrefix = "snitcher" + applyDefaultHierarchyTemplate() - lint { - abortOnError = false - } -} + sourceSets { + val jvmSharedMain = create("jvmSharedMain") + jvmSharedMain.dependsOn(commonMain.get()) + getByName("desktopMain").dependsOn(jvmSharedMain) + getByName("androidMain").dependsOn(jvmSharedMain) -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get())) - freeCompilerArgs.add("-Xexplicit-api=strict") - } -} + commonMain.dependencies { + // these carry types of the public API, such as StateFlow, Color, TextStyle and Modifier. + api(libs.compose.runtime) + api(libs.compose.foundation) + api(libs.compose.ui) + api(libs.coroutines) + api(libs.kotlinx.serialization.core) + + implementation(libs.compose.material) + implementation(libs.kotlinx.serialization.json) + implementation(libs.okio) + } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.coroutines.test) + } + + androidMain.dependencies { + // ExceptionTraceActivity extends ComponentActivity, which consumers subclass. + api(libs.androidx.activity.compose) -tasks.withType(JavaCompile::class.java).configureEach { - this.targetCompatibility = libs.versions.jvmTarget.get() - this.sourceCompatibility = libs.versions.jvmTarget.get() + implementation(libs.androidx.core.ktx) + } + + } } baselineProfile { @@ -83,23 +129,5 @@ baselineProfile { } dependencies { - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.material) - implementation(libs.androidx.compose.material.iconsExtended) - implementation(libs.androidx.core.ktx) - - implementation(libs.androidx.datastore) - implementation(libs.androidx.datastore.core) - - implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.serialization.protobuf) - - implementation(libs.coroutines) - baselineProfile(project(":benchmark")) } diff --git a/snitcher/src/main/AndroidManifest.xml b/snitcher/src/androidMain/AndroidManifest.xml similarity index 95% rename from snitcher/src/main/AndroidManifest.xml rename to snitcher/src/androidMain/AndroidManifest.xml index 76c3080..0b728d0 100644 --- a/snitcher/src/main/AndroidManifest.xml +++ b/snitcher/src/androidMain/AndroidManifest.xml @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - + diff --git a/snitcher/src/main/generated/baselineProfiles/baseline-prof.txt b/snitcher/src/androidMain/baselineProfiles/baseline-prof.txt similarity index 88% rename from snitcher/src/main/generated/baselineProfiles/baseline-prof.txt rename to snitcher/src/androidMain/baselineProfiles/baseline-prof.txt index bfba4b6..7d856d8 100644 --- a/snitcher/src/main/generated/baselineProfiles/baseline-prof.txt +++ b/snitcher/src/androidMain/baselineProfiles/baseline-prof.txt @@ -1,9 +1,7 @@ -Lcom/skydoves/snitcher/R$string; Lcom/skydoves/snitcher/Snitcher; HSPLcom/skydoves/snitcher/Snitcher;->()V HSPLcom/skydoves/snitcher/Snitcher;->(Landroid/app/Application;Ljava/lang/String;Lkotlin/reflect/KClass;Lcom/skydoves/snitcher/TraceStrategy;Ljava/lang/Thread$UncaughtExceptionHandler;Lkotlin/jvm/functions/Function2;)V HSPLcom/skydoves/snitcher/Snitcher;->access$getApplication$p(Lcom/skydoves/snitcher/Snitcher;)Landroid/app/Application; -HSPLcom/skydoves/snitcher/Snitcher;->access$getDataStore$p(Lcom/skydoves/snitcher/Snitcher;)Lcom/skydoves/snitcher/datastore/SnitcherDataStore; HSPLcom/skydoves/snitcher/Snitcher;->access$getException$cp()Lkotlinx/coroutines/flow/StateFlow; HSPLcom/skydoves/snitcher/Snitcher;->access$getLauncher$cp()Lkotlinx/coroutines/flow/StateFlow; HSPLcom/skydoves/snitcher/Snitcher;->access$get_exception$cp()Lkotlinx/coroutines/flow/MutableStateFlow; @@ -53,17 +51,6 @@ Lcom/skydoves/snitcher/TraceStrategy; HSPLcom/skydoves/snitcher/TraceStrategy;->$values()[Lcom/skydoves/snitcher/TraceStrategy; HSPLcom/skydoves/snitcher/TraceStrategy;->()V HSPLcom/skydoves/snitcher/TraceStrategy;->(Ljava/lang/String;I)V -Lcom/skydoves/snitcher/datastore/SnitcherDataStore; -HSPLcom/skydoves/snitcher/datastore/SnitcherDataStore;->(Landroidx/datastore/core/DataStore;)V -HSPLcom/skydoves/snitcher/datastore/SnitcherDataStore;->getData()Lkotlinx/coroutines/flow/Flow; -Lcom/skydoves/snitcher/datastore/SnitcherSerializer; -HSPLcom/skydoves/snitcher/datastore/SnitcherSerializer;->()V -HSPLcom/skydoves/snitcher/datastore/SnitcherSerializer;->readFrom(Ljava/io/InputStream;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Lcom/skydoves/snitcher/installer/SnitcherInstaller; -HSPLcom/skydoves/snitcher/installer/SnitcherInstaller;->()V -HSPLcom/skydoves/snitcher/installer/SnitcherInstaller;->()V -HSPLcom/skydoves/snitcher/installer/SnitcherInstaller;->getInternalSnitcher()Lkotlinx/coroutines/flow/MutableStateFlow; -HSPLcom/skydoves/snitcher/installer/SnitcherInstaller;->install(Lcom/skydoves/snitcher/Snitcher;)V Lcom/skydoves/snitcher/model/SnitcherException; HSPLcom/skydoves/snitcher/model/SnitcherException;->()V HSPLcom/skydoves/snitcher/model/SnitcherException;->(IJLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V @@ -176,20 +163,10 @@ HSPLcom/skydoves/snitcher/ui/theme/SnitcherThemeKt$SnitcherTheme$1;->invoke(Land HSPLcom/skydoves/snitcher/ui/theme/SnitcherThemeKt$SnitcherTheme$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Lcom/skydoves/snitcher/ui/theme/SnitcherThemeKt$SnitcherTheme$2; HSPLcom/skydoves/snitcher/ui/theme/SnitcherThemeKt$SnitcherTheme$2;->(ZLcom/skydoves/snitcher/ui/theme/SnitcherColor;Lkotlin/jvm/functions/Function2;II)V -Lcom/skydoves/snitcher/ui/theme/StatusbarKt; -HSPLcom/skydoves/snitcher/ui/theme/StatusbarKt;->SnitcherStatusBarColor-Iv8Zu3U(JLandroidx/compose/runtime/Composer;II)V -Lcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$1$1; -HSPLcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$1$1;->(Lcom/google/accompanist/systemuicontroller/SystemUiController;JZ)V -HSPLcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$1$1;->invoke(Landroidx/compose/runtime/DisposableEffectScope;)Landroidx/compose/runtime/DisposableEffectResult; -HSPLcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$1$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$1$1$invoke$$inlined$onDispose$1; -HSPLcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$1$1$invoke$$inlined$onDispose$1;->()V -Lcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$2; -HSPLcom/skydoves/snitcher/ui/theme/StatusbarKt$SnitcherStatusBarColor$2;->(JII)V Lcom/skydoves/snitcherdemo/SnitcherDemoApp; HSPLcom/skydoves/snitcherdemo/SnitcherDemoApp;->()V HSPLcom/skydoves/snitcherdemo/SnitcherDemoApp;->()V HSPLcom/skydoves/snitcherdemo/SnitcherDemoApp;->onCreate()V Lcom/skydoves/snitcherdemo/SnitcherDemoApp$onCreate$1; HSPLcom/skydoves/snitcherdemo/SnitcherDemoApp$onCreate$1;->()V -HSPLcom/skydoves/snitcherdemo/SnitcherDemoApp$onCreate$1;->()V \ No newline at end of file +HSPLcom/skydoves/snitcherdemo/SnitcherDemoApp$onCreate$1;->()V diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/datastore/SnitcherDataStore.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/Launcher.android.kt similarity index 61% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/datastore/SnitcherDataStore.kt rename to snitcher/src/androidMain/kotlin/com/skydoves/snitcher/Launcher.android.kt index 88d76df..5dbe357 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/datastore/SnitcherDataStore.kt +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/Launcher.android.kt @@ -13,15 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.skydoves.snitcher.datastore +package com.skydoves.snitcher -import androidx.datastore.core.DataStore -import com.skydoves.snitcher.model.SnitcherPreference +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow -internal class SnitcherDataStore(dataStore: DataStore) : - DataStore by dataStore { - - internal suspend fun updateSnitcherException(snitcherPreference: SnitcherPreference) { - updateData { snitcherPreference } - } -} +/** + * Depicts the state flow of the launcher Activity that will be started when your application is + * recovered from the exception tracing screen. + */ +public val Snitcher.launcher: StateFlow + get() = launcherFlow.asStateFlow() diff --git a/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/Snitcher.android.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/Snitcher.android.kt new file mode 100644 index 0000000..c13170f --- /dev/null +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/Snitcher.android.kt @@ -0,0 +1,144 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher + +import android.app.Activity +import android.app.Application +import android.content.pm.ApplicationInfo +import android.os.Build +import com.skydoves.snitcher.extensions.packageInfo +import com.skydoves.snitcher.extensions.versionCode +import com.skydoves.snitcher.internal.SnitcherExceptionHandler +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.storage.SNITCHER_STORE_FILE_NAME +import com.skydoves.snitcher.storage.SnitcherStore +import com.skydoves.snitcher.ui.ExceptionTraceActivity +import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig +import okio.FileSystem +import okio.Path.Companion.toOkioPath +import kotlin.reflect.KClass + +/** + * Installs Snitcher as the default uncaught exception handler of the application, which captures + * every crash, persists it, and launches the exception tracing activity. + * + * @param application The [Application] to register for tracing activity lifecycles. + * @param traceActivity An Activity that will be launched when your app experiences a crash. + * @param traceStrategy The trace strategy determines the launch behaviors when your app experiences a crash. + * @param theme The theme that styles the pre-built exception tracing screens. + * @param strings The texts of the pre-built exception tracing screens. + * @param exceptionHandler You can manage extra exception handlers, like logging exceptions on Firebase, by providing this lambda function here. + * This handler will be called with a given [SnitcherException] when your app encounters exceptions. + * It runs on the crashing thread, before the process is killed, so keep it short and do not block + * on the network. + */ +public fun Snitcher.install( + application: Application, + traceActivity: KClass<*> = ExceptionTraceActivity::class, + traceStrategy: TraceStrategy = TraceStrategy.CO_WORK, + theme: SnitcherThemeConfig = SnitcherThemeConfig(), + strings: SnitcherStrings = SnitcherStrings(), + exceptionHandler: (SnitcherException) -> Unit = {}, +) { + installInternal( + application = application, + launcher = null, + traceActivity = traceActivity, + traceStrategy = traceStrategy, + theme = theme, + strings = strings, + exceptionHandler = exceptionHandler, + ) +} + +/** + * Installs Snitcher as the default uncaught exception handler of the application, and restores the + * application from the given [launcher] activity instead of from the most recent one. + * + * @param application The [Application] to register for tracing activity lifecycles. + * @param traceActivity An Activity that will be launched when your app experiences a crash. + * @param traceStrategy The trace strategy determines the launch behaviors when your app experiences a crash. + * @param launcher A launcher Activity that used to restore your application from a crash. + * @param theme The theme that styles the pre-built exception tracing screens. + * @param strings The texts of the pre-built exception tracing screens. + * @param exceptionHandler You can manage extra exception handlers, like logging exceptions on Firebase, by providing this lambda function here. + * This handler will be called with a given [SnitcherException] when your app encounters exceptions. + */ +public inline fun Snitcher.install( + application: Application, + traceActivity: KClass<*> = ExceptionTraceActivity::class, + traceStrategy: TraceStrategy = TraceStrategy.CO_WORK, + launcher: KClass = T::class, + theme: SnitcherThemeConfig = SnitcherThemeConfig(), + strings: SnitcherStrings = SnitcherStrings(), + noinline exceptionHandler: (SnitcherException) -> Unit = {}, +) { + installInternal( + application = application, + launcher = launcher.java.name, + traceActivity = traceActivity, + traceStrategy = traceStrategy, + theme = theme, + strings = strings, + exceptionHandler = exceptionHandler, + ) +} + +@PublishedApi +internal fun Snitcher.installInternal( + application: Application, + launcher: String?, + traceActivity: KClass<*>, + traceStrategy: TraceStrategy, + theme: SnitcherThemeConfig, + strings: SnitcherStrings, + exceptionHandler: (SnitcherException) -> Unit, +) { + val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() ?: return + + this.theme = theme + this.strings = strings + this.platformInfo = application.platformInfo() + this.isDebuggable = application.isDebuggableApp + + bind( + store = SnitcherStore( + fileSystem = FileSystem.SYSTEM, + path = application.filesDir.toOkioPath().resolve(SNITCHER_STORE_FILE_NAME), + ), + launcher = launcher, + exceptionHandler = exceptionHandler, + ) + + Thread.setDefaultUncaughtExceptionHandler( + SnitcherExceptionHandler( + application = application, + launcher = launcher, + traceActivityClass = traceActivity, + traceStrategy = traceStrategy, + defaultExceptionHandler = defaultExceptionHandler, + ), + ) +} + +private val Application.isDebuggableApp: Boolean + get() = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + +private fun Application.platformInfo(): String { + val packageInfo = packageInfo() + return "${packageInfo?.versionName} (${packageInfo?.versionCode()}) " + + "${Build.MANUFACTURER} ${Build.MODEL}" +} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/TraceStrategy.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/TraceStrategy.kt similarity index 100% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/TraceStrategy.kt rename to snitcher/src/androidMain/kotlin/com/skydoves/snitcher/TraceStrategy.kt diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/extensions/ContextExtensions.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/extensions/ContextExtensions.kt similarity index 70% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/extensions/ContextExtensions.kt rename to snitcher/src/androidMain/kotlin/com/skydoves/snitcher/extensions/ContextExtensions.kt index 435ea66..577e9c8 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/extensions/ContextExtensions.kt +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/extensions/ContextExtensions.kt @@ -18,14 +18,10 @@ package com.skydoves.snitcher.extensions import android.app.Activity import android.content.Context import android.content.ContextWrapper +import android.content.Intent import android.content.pm.PackageInfo import android.os.Build -import android.widget.Toast - -@JvmSynthetic -internal fun Context.toast(message: String) { - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() -} +import com.skydoves.snitcher.Snitcher @JvmSynthetic internal fun Context.findActivity(): Activity? { @@ -49,5 +45,25 @@ internal fun PackageInfo?.versionCode(): Long? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { this?.longVersionCode } else { + @Suppress("DEPRECATION") this?.versionCode?.toLong() } + +/** Restarts the application from the given [launcher] activity and finishes the current one. */ +@JvmSynthetic +internal fun Context.restoreApp(launcher: String) { + if (launcher.isEmpty()) { + return + } + + // the crash was handled, so it must not be displayed again on the next launch. + Snitcher.clear() + + startActivity( + Intent().setClassName(this, launcher).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + }, + ) + + findActivity()?.finish() +} diff --git a/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/internal/SnitcherExceptionHandler.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/internal/SnitcherExceptionHandler.kt new file mode 100644 index 0000000..c9f6e25 --- /dev/null +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/internal/SnitcherExceptionHandler.kt @@ -0,0 +1,151 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.internal + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import android.os.Process +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.TraceStrategy +import com.skydoves.snitcher.ui.ExceptionTraceActivity +import java.util.concurrent.atomic.AtomicInteger +import kotlin.reflect.KClass +import kotlin.system.exitProcess + +/** + * A universal exception handler that captures every exception within the application and directs + * it to the exception tracing screen. + */ +internal class SnitcherExceptionHandler( + application: Application, + private val launcher: String?, + private val traceActivityClass: KClass<*>, + private val traceStrategy: TraceStrategy, + private val defaultExceptionHandler: Thread.UncaughtExceptionHandler, +) : Thread.UncaughtExceptionHandler { + + @Volatile + private var lastActivity: Activity? = null + private val activityCount = AtomicInteger(0) + + init { + application.registerActivityLifecycleCallbacks( + object : Application.ActivityLifecycleCallbacks { + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) { + if (lastActivity === activity) { + lastActivity = null + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (isExceptionActivity(activity)) { + return + } + + lastActivity = activity + } + + override fun onActivityStarted(activity: Activity) { + if (isExceptionActivity(activity)) { + return + } + activityCount.incrementAndGet() + lastActivity = activity + } + + override fun onActivityStopped(activity: Activity) { + if (isExceptionActivity(activity)) { + return + } + if (activityCount.decrementAndGet() < 0) { + lastActivity = null + } + } + }, + ) + } + + private fun isExceptionActivity(activity: Activity) = activity is ExceptionTraceActivity + + override fun uncaughtException(thread: Thread, throwable: Throwable) { + val activity = lastActivity + + if (Snitcher.isRethrowing) { + // the debug action threw the recorded crash again, so the record stays as it is. + Snitcher.isRethrowing = false + } else { + // persists the crash synchronously, so that it survives the process death below. It runs even + // without a foreground activity, where the crash can still be reported on the next launch. + Snitcher.capture(throwable = throwable, launcher = launcher ?: activity?.javaClass?.name) + } + + if (activity == null) { + defaultExceptionHandler.uncaughtException(thread, throwable) + return + } + + try { + // call the default exception handler for integrating with other libraries. + callDefaultExceptionHandler(thread, throwable) + + if (traceStrategy == TraceStrategy.CO_WORK) { + launchExceptionTracingActivity(activity) + } + } catch (_: Throwable) { + // a failure here must not leave the process alive with a dead thread. + } finally { + // kill the current process. + Process.killProcess(Process.myPid()) + exitProcess(EXIT_CODE) + } + } + + private fun launchExceptionTracingActivity(activity: Activity) = activity.run { + val traceActivity = Class.forName(traceActivityClass.java.name) + + startActivity( + Intent().setClass(this, traceActivity).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + }, + ) + finish() + } + + private fun callDefaultExceptionHandler(thread: Thread, throwable: Throwable) { + try { + val canonicalName = defaultExceptionHandler::class.java.canonicalName + if (canonicalName?.startsWith("com.android.internal.os") != true && + canonicalName?.startsWith("com.google.firebase.crashlytics") != true + ) { + defaultExceptionHandler.uncaughtException(thread, throwable) + } + } catch (_: Throwable) { + } + } + + private companion object { + private const val EXIT_CODE = 10 + } +} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/ExceptionTraceActivity.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/ExceptionTraceActivity.kt similarity index 91% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/ExceptionTraceActivity.kt rename to snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/ExceptionTraceActivity.kt index 0a76137..15ec2ce 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/ExceptionTraceActivity.kt +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/ExceptionTraceActivity.kt @@ -22,6 +22,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.launcher import com.skydoves.snitcher.model.SnitcherException import com.skydoves.snitcher.ui.theme.SnitcherTheme @@ -43,11 +44,12 @@ public open class ExceptionTraceActivity : ComponentActivity() { val launcher: String by Snitcher.launcher.collectAsState() SnitcherTheme { - if (exception != null) { + val snitcherException = exception + if (snitcherException != null) { if (Snitcher.isDebuggable) { ExceptionTraceScreen( launcher = launcher, - snitcherException = exception!!, + snitcherException = snitcherException, ) } else { AppRestoreScreen(launcher = launcher) diff --git a/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/SnitcherScreens.android.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/SnitcherScreens.android.kt new file mode 100644 index 0000000..2686257 --- /dev/null +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/SnitcherScreens.android.kt @@ -0,0 +1,65 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.debug +import com.skydoves.snitcher.extensions.restoreApp +import com.skydoves.snitcher.model.SnitcherException + +/** + * Displays the captured [snitcherException] with the actions that Android supports, which are + * restoring the application from the [launcher] activity and throwing the exception again for a + * debugger. + * + * @param launcher The class name of the activity that restores the application. + * @param snitcherException The captured crash to render. + * @param modifier The modifier of the screen. + */ +@Composable +public fun ExceptionTraceScreen( + launcher: String, + snitcherException: SnitcherException, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + ExceptionTraceScreen( + snitcherException = snitcherException, + modifier = modifier, + onRestore = { context.restoreApp(launcher) }, + onDebug = { Snitcher.debug(snitcherException) }, + ) +} + +/** + * Displays the recovery screen, which restores the application from the [launcher] activity. + * + * @param launcher The class name of the activity that restores the application. + * @param modifier The modifier of the screen. + */ +@Composable +public fun AppRestoreScreen(launcher: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + + AppRestoreScreen( + modifier = modifier, + onRestore = { context.restoreApp(launcher) }, + ) +} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/Statusbar.kt b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.android.kt similarity index 91% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/Statusbar.kt rename to snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.android.kt index 41ea395..9ba354a 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/Statusbar.kt +++ b/snitcher/src/androidMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.android.kt @@ -28,13 +28,13 @@ import com.skydoves.snitcher.extensions.findActivity * bars by the Snitcher screens. The previous appearance is restored when this leaves composition. */ @Composable -internal fun SnitcherStatusBarColor(color: Color = SnitcherTheme.colors.background) { +internal actual fun SnitcherSystemBars(color: Color) { val view = LocalView.current if (view.isInEditMode) { return } - val darkIcons = color.luminance() > 0.5f + val darkIcons = color.luminance() > LIGHT_BACKGROUND_LUMINANCE DisposableEffect(view, darkIcons) { val window = view.context.findActivity()?.window ?: return@DisposableEffect onDispose { } @@ -51,3 +51,5 @@ internal fun SnitcherStatusBarColor(color: Color = SnitcherTheme.colors.backgrou } } } + +private const val LIGHT_BACKGROUND_LUMINANCE = 0.5f diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/Snitcher.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/Snitcher.kt new file mode 100644 index 0000000..5337b64 --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/Snitcher.kt @@ -0,0 +1,150 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.skydoves.snitcher.internal.toSnitcherException +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.model.SnitcherPreference +import com.skydoves.snitcher.storage.SnitcherStore +import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig +import com.skydoves.snitcher.ui.theme.resolved +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The shared state of Snitcher across every platform. It holds the most recent crash, the theme and + * the texts of the pre-built screens, and it persists a crash so that it survives the process death + * that follows it. + * + * Install Snitcher with the platform installer, `Snitcher.install(..)`, which is provided by the + * Android, desktop, and iOS source sets. + */ +public object Snitcher { + + private val _exception: MutableStateFlow = MutableStateFlow(null) + + /** Represents the state flow of the most recent [SnitcherException] encountered by an application. */ + public val exception: StateFlow = _exception.asStateFlow() + + /** + * The most recent [SnitcherException], or null when the application did not crash. It is the + * typed counterpart of `exception.value`, which Objective-C and Swift only see as `Any?`. + */ + public val latestException: SnitcherException? + get() = _exception.value + + /** + * The launcher that will be started when your application is recovered from the exception tracing + * screen. It only exists on Android, which is the only platform that can relaunch an application, + * and the Android source set exposes it as `Snitcher.launcher`. + */ + internal val launcherFlow: MutableStateFlow = MutableStateFlow("") + + private var themeConfig: SnitcherThemeConfig by mutableStateOf(SnitcherThemeConfig().resolved()) + + /** + * The theme that styles the pre-built Snitcher screens. Colors that were left unspecified are + * resolved to the color they follow when the theme is assigned, so readers never meet + * [androidx.compose.ui.graphics.Color.Unspecified]. + */ + public var theme: SnitcherThemeConfig + get() = themeConfig + set(value) { + themeConfig = value.resolved() + } + + /** The texts that are displayed by the pre-built Snitcher screens. */ + public var strings: SnitcherStrings by mutableStateOf(SnitcherStrings()) + + /** + * The application and device description that is rendered under the exception message, such as + * `1.0.3 (4) Google Pixel 9`. Each platform installer fills it in. + */ + public var platformInfo: String by mutableStateOf("") + + /** + * Whether the full exception trace screen should be displayed instead of the restore screen. + * The platform installers set it from the build type, and you can override it afterwards. + */ + public var isDebuggable: Boolean by mutableStateOf(true) + + /** Whether the platform installer was already called. */ + public val isInstalled: Boolean + get() = store != null + + internal var store: SnitcherStore? = null + private set + + internal var exceptionHandler: (SnitcherException) -> Unit = {} + private set + + /** + * Whether the exception that is about to arrive was thrown again on purpose, by the debug action + * of the trace screen, in which case it must not replace the crash that is already recorded. + */ + internal var isRethrowing: Boolean = false + + /** Removes the persisted crash, so that it is not displayed again on the next launch. */ + public fun clear() { + store?.clear() + _exception.value = null + launcherFlow.value = "" + } + + internal fun bind( + store: SnitcherStore, + launcher: String?, + exceptionHandler: (SnitcherException) -> Unit, + ) { + this.store = store + this.exceptionHandler = exceptionHandler + + val preference = store.read() + if (preference != null) { + _exception.value = preference.snitcherException + launcherFlow.value = preference.launcher.orEmpty() + } else if (launcher != null) { + launcherFlow.value = launcher + } + } + + /** + * Captures the given [throwable], persists it synchronously, and runs the custom exception + * handler. It is called by the platform installers and returns the captured model. + */ + internal fun capture(throwable: Throwable, launcher: String?): SnitcherException = + capture(throwable.toSnitcherException(), launcher) + + /** + * Persists an already built [exception] synchronously and runs the custom exception handler. + */ + internal fun capture(exception: SnitcherException, launcher: String?): SnitcherException { + val resolvedLauncher = launcher ?: launcherFlow.value.takeIf { it.isNotEmpty() } + + store?.write(SnitcherPreference(snitcherException = exception, launcher = resolvedLauncher)) + _exception.value = exception + if (resolvedLauncher != null) { + launcherFlow.value = resolvedLauncher + } + + runCatching { exceptionHandler.invoke(exception) } + return exception + } +} diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/SnitcherStrings.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/SnitcherStrings.kt new file mode 100644 index 0000000..29c3b7b --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/SnitcherStrings.kt @@ -0,0 +1,54 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher + +import androidx.compose.runtime.Immutable + +/** + * The texts that are displayed by the pre-built Snitcher screens. Give an instance of this class to + * the `strings` parameter of the platform installers, or assign [Snitcher.strings] directly, to + * translate or reword them. + * + * @property restoreTitle The title of the restore screen, which is shown on a release build. + * @property restoreDescription The description of the restore screen. + * @property restoreButton The label of the button that restores the app from the restore screen. + * @property traceRestoreButton The label of the button that restores the app from the trace screen. + * @property traceDebugButton The label of the button that throws the exception again for a debugger. + * @property traceStacktrace The label of the stack trace section. + * @property traceCopied The message that confirms the stack trace was copied. + */ +@Immutable +public data class SnitcherStrings( + val restoreTitle: String = "Oops, Restore the previous screen?", + val restoreDescription: String = + "The app crashed unexpectedly. We apologize for the inconvenience. " + + "Would you like to return to where you left off?", + val restoreButton: String = "Restore", + val traceRestoreButton: String = "Restore App", + val traceDebugButton: String = "Debug on IDE", + val traceStacktrace: String = "Stacktrace", + val traceCopied: String = "Copied!", +) { + public companion object { + /** + * Provides the default texts, which is also the entry point for Swift, where Kotlin default + * arguments are not available. + * + * @return A [SnitcherStrings] instance holding our texts. + */ + public fun defaultStrings(): SnitcherStrings = SnitcherStrings() + } +} diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/internal/Platform.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/internal/Platform.kt new file mode 100644 index 0000000..6a27b1d --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/internal/Platform.kt @@ -0,0 +1,44 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.internal + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.model.SnitcherStackTraceElement + +/** The name of the thread that is running the current code. */ +internal expect fun currentThreadName(): String + +/** The id of the thread that is running the current code, or `0` where there is none. */ +internal expect fun currentThreadId(): Long + +/** The structured stack trace frames, which are only available on the JVM. */ +internal expect fun Throwable.snitcherStackTraceElements(): List + +/** + * Rebuilds a [Throwable] out of a captured exception so that it can be thrown again for a debugger, + * or `null` where the platform cannot restore one. + */ +internal expect fun SnitcherException.restoreThrowable(): Throwable? + +/** Converts a [Throwable] into the serializable model that Snitcher stores and renders. */ +internal fun Throwable.toSnitcherException(): SnitcherException = SnitcherException( + threadId = currentThreadId(), + threadName = currentThreadName(), + packageName = this::class.simpleName.orEmpty(), + message = message.orEmpty(), + stackTrace = stackTraceToString(), + stackTraceElement = snitcherStackTraceElements(), +) diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherException.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherException.kt similarity index 60% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherException.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherException.kt index 6bbcfa1..83ae936 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherException.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherException.kt @@ -16,25 +16,27 @@ package com.skydoves.snitcher.model import com.skydoves.snitcher.Snitcher -import java.io.Serializable +import kotlinx.serialization.Serializable /** * Crash information, captured and provided by [Snitcher], * encompasses essential data about exceptions, including detailed stack traces. + * + * @property threadId The id of the thread that threw the exception, or `0` where the platform does + * not expose one. + * @property threadName The name of the thread that threw the exception. + * @property packageName The simple name of the thrown exception class. + * @property message The message of the thrown exception. + * @property stackTrace The whole stack trace, rendered as a string. + * @property stackTraceElement The stack trace frames. Kotlin/Native does not expose structured + * frames, so this list is empty on iOS. */ -@kotlinx.serialization.Serializable +@Serializable public data class SnitcherException( public val threadId: Long, public val threadName: String, public val packageName: String, public val message: String, public val stackTrace: String, - public val stackTraceElement: List, -) : Serializable { - - /** Creates a [Throwable] from the given [SnitcherException]. */ - public val throwable: Throwable - get() = Throwable(message).apply { - stackTrace = stackTraceElement.map { it.toStackTraceElement() }.toTypedArray() - } -} + public val stackTraceElement: List = emptyList(), +) diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherPreference.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherPreference.kt similarity index 89% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherPreference.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherPreference.kt index 3b67fc4..d5f7216 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherPreference.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherPreference.kt @@ -15,8 +15,10 @@ */ package com.skydoves.snitcher.model -@kotlinx.serialization.Serializable +import kotlinx.serialization.Serializable + +@Serializable internal data class SnitcherPreference( val snitcherException: SnitcherException, - val launcher: String?, + val launcher: String? = null, ) diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherStackTraceElement.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherStackTraceElement.kt similarity index 68% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherStackTraceElement.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherStackTraceElement.kt index 6d7a426..7e8459a 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/model/SnitcherStackTraceElement.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/model/SnitcherStackTraceElement.kt @@ -17,6 +17,7 @@ package com.skydoves.snitcher.model import kotlinx.serialization.Serializable +/** A single frame of a captured stack trace. */ @Serializable public data class SnitcherStackTraceElement( public val fileName: String, @@ -24,16 +25,3 @@ public data class SnitcherStackTraceElement( public val className: String, public val methodName: String, ) - -@JvmSynthetic -internal fun StackTraceElement.toSnitcherElement(): SnitcherStackTraceElement = - SnitcherStackTraceElement( - fileName = fileName.orEmpty(), - lineNumber = lineNumber, - className = className, - methodName = methodName, - ) - -@JvmSynthetic -internal fun SnitcherStackTraceElement.toStackTraceElement(): StackTraceElement = - StackTraceElement(className, methodName, fileName, lineNumber) diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/storage/SnitcherStore.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/storage/SnitcherStore.kt new file mode 100644 index 0000000..f2ece2f --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/storage/SnitcherStore.kt @@ -0,0 +1,85 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.storage + +import com.skydoves.snitcher.model.SnitcherPreference +import kotlinx.serialization.json.Json +import okio.FileSystem +import okio.Path + +/** The name of the file that holds the last captured crash. */ +internal const val SNITCHER_STORE_FILE_NAME: String = "snitcher.json" + +/** + * A tiny synchronous store for the last captured crash. + * + * Every read and write is blocking on purpose. A crash is written while the process is being torn + * down, where a suspending write is not guaranteed to reach the disk, and the payload is a single + * small document. + */ +internal class SnitcherStore(private val fileSystem: FileSystem, private val path: Path) { + + fun read(): SnitcherPreference? { + if (!fileSystem.exists(path)) { + return null + } + + return runCatching { + val content = fileSystem.read(path) { readUtf8() } + if (content.isBlank()) { + null + } else { + json.decodeFromString(SnitcherPreference.serializer(), content) + } + }.getOrElse { + // a file that cannot be decoded is a leftover of a crash that could not be written whole, + // and it would fail every launch from now on, so it goes. + clear() + null + } + } + + /** + * Writes the crash and returns whether it reached the disk. + * + * The payload goes to a temporary file first and is then moved onto [path], so a reader never + * meets a half written file, even when the process is killed in the middle of a crash. + */ + fun write(preference: SnitcherPreference): Boolean = runCatching { + val directory = path.parent + if (directory != null) { + fileSystem.createDirectories(directory) + } + + val temporaryPath = path.parent?.resolve("$TEMPORARY_PREFIX${path.name}") ?: path + fileSystem.write(temporaryPath) { + writeUtf8(json.encodeToString(SnitcherPreference.serializer(), preference)) + } + if (temporaryPath != path) { + fileSystem.atomicMove(temporaryPath, path) + } + true + }.getOrElse { false } + + fun clear() { + runCatching { fileSystem.delete(path, mustExist = false) } + } + + private companion object { + private const val TEMPORARY_PREFIX = "~" + private val json = Json { ignoreUnknownKeys = true } + } +} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/AppRestoreScreen.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/AppRestoreScreen.kt similarity index 56% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/AppRestoreScreen.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/AppRestoreScreen.kt index 99386c7..4c24742 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/AppRestoreScreen.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/AppRestoreScreen.kt @@ -15,8 +15,6 @@ */ package com.skydoves.snitcher.ui -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -24,32 +22,28 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.skydoves.snitcher.R -import com.skydoves.snitcher.extensions.findActivity -import com.skydoves.snitcher.ui.theme.SnitcherStatusBarColor +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.ui.theme.SnitcherSystemBars import com.skydoves.snitcher.ui.theme.SnitcherTheme +/** + * Displays a friendly recovery screen, which hides the stack trace and is meant for release builds. + * + * @param modifier The modifier of the screen. + * @param onRestore Restores the application. The button is hidden when it is null. + */ @Composable -public fun AppRestoreScreen(launcher: String) { - SnitcherStatusBarColor() +public fun AppRestoreScreen(modifier: Modifier = Modifier, onRestore: (() -> Unit)? = null) { + SnitcherSystemBars() - AppRestoreScreenContent(packageName = launcher) -} - -@Composable -private fun AppRestoreScreenContent(packageName: String) { - val context = LocalContext.current + val strings = Snitcher.strings Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(SnitcherTheme.colors.background) .systemBarsPadding() @@ -58,32 +52,25 @@ private fun AppRestoreScreenContent(packageName: String) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stringResource(id = R.string.snitcher_release_crash_screen_title), + text = strings.restoreTitle, color = SnitcherTheme.colors.textHighEmphasis, style = SnitcherTheme.typography.title, ) Text( modifier = Modifier.padding(vertical = 18.dp), - text = stringResource(id = R.string.snitcher_release_crash_screen_description), + text = strings.restoreDescription, color = SnitcherTheme.colors.textHighEmphasis, style = SnitcherTheme.typography.message, ) - SnitcherPrimaryButton( - modifier = Modifier.padding(vertical = 48.dp), - icon = Icons.Filled.RestartAlt, - text = stringResource(id = R.string.snitcher_release_crash_screen_restore), - onClick = { - context.startActivity( - Intent().setClassName(context, packageName).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK - }, - ) - - val activity = context.findActivity() - activity?.finish() - }, - ) + if (onRestore != null) { + SnitcherPrimaryButton( + modifier = Modifier.padding(vertical = 48.dp), + icon = SnitcherIcons.Restore, + text = strings.restoreButton, + onClick = onRestore, + ) + } } } diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/ExceptionTraceScreen.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/ExceptionTraceScreen.kt new file mode 100644 index 0000000..5c1a737 --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/ExceptionTraceScreen.kt @@ -0,0 +1,194 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.ui.theme.SnitcherSystemBars +import com.skydoves.snitcher.ui.theme.SnitcherTheme +import kotlinx.coroutines.delay + +/** + * Displays the captured [snitcherException] with the stack trace, and the actions that the host + * platform supports. + * + * @param snitcherException The captured crash to render. + * @param modifier The modifier of the screen. + * @param onRestore Restores the application. The button is hidden when it is null. + * @param onDebug Throws the exception again so that a debugger catches it. The button is hidden + * when it is null, which is the case on platforms that cannot restore a throwable. + */ +@Composable +public fun ExceptionTraceScreen( + snitcherException: SnitcherException, + modifier: Modifier = Modifier, + onRestore: (() -> Unit)? = null, + onDebug: (() -> Unit)? = null, +) { + SnitcherSystemBars() + + val clipboardManager = LocalClipboardManager.current + val scrollState = rememberScrollState() + val strings = Snitcher.strings + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(COPIED_MESSAGE_DURATION) + copied = false + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(SnitcherTheme.colors.background) + .systemBarsPadding() + .verticalScroll(scrollState) + .padding(16.dp) + .testTag("exception_trace_screen"), + ) { + val title = remember(snitcherException) { snitcherException.title() } + + Text( + text = title, + color = SnitcherTheme.colors.primary, + style = SnitcherTheme.typography.title, + ) + + Text( + modifier = Modifier.padding(vertical = 6.dp), + text = snitcherException.message, + color = SnitcherTheme.colors.textHighEmphasis, + style = SnitcherTheme.typography.message, + ) + + val platformInfo = Snitcher.platformInfo + if (platformInfo.isNotEmpty()) { + Text( + modifier = Modifier.padding(vertical = 6.dp), + text = platformInfo, + color = SnitcherTheme.colors.textLowEmphasis, + style = SnitcherTheme.typography.deviceInfo, + ) + } + + if (onRestore != null) { + SnitcherPrimaryButton( + modifier = Modifier.padding(vertical = 16.dp), + icon = SnitcherIcons.Restore, + text = strings.traceRestoreButton, + onClick = onRestore, + ) + } + + if (onDebug != null) { + SnitcherPrimaryButton( + modifier = Modifier.padding(bottom = 16.dp), + icon = SnitcherIcons.Debug, + text = strings.traceDebugButton, + onClick = onDebug, + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.align(Alignment.CenterStart), + text = strings.traceStacktrace, + color = SnitcherTheme.colors.primary, + style = SnitcherTheme.typography.sectionLabel, + ) + + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { + clipboardManager.setText(AnnotatedString(snitcherException.stackTrace)) + copied = true + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + if (copied) { + Text( + text = strings.traceCopied, + color = SnitcherTheme.colors.textLowEmphasis, + style = SnitcherTheme.typography.sectionLabel, + ) + + Spacer(modifier = Modifier.width(6.dp)) + } + + Icon( + imageVector = SnitcherIcons.Copy, + tint = SnitcherTheme.colors.textHighEmphasis, + contentDescription = "copy", + ) + } + } + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + modifier = Modifier + .border( + border = BorderStroke(2.dp, SnitcherTheme.colors.outline), + shape = SnitcherTheme.shapes.stacktrace, + ) + .padding(12.dp), + text = snitcherException.stackTrace, + color = SnitcherTheme.colors.textHighEmphasis, + style = SnitcherTheme.typography.stacktrace, + ) + } +} + +private const val COPIED_MESSAGE_DURATION = 2000L + +private fun SnitcherException.title(): String = packageName.ifEmpty { + stackTrace.split(":").firstOrNull()?.split(".")?.last().orEmpty() +} diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/SnitcherIcons.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/SnitcherIcons.kt new file mode 100644 index 0000000..00edff9 --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/SnitcherIcons.kt @@ -0,0 +1,68 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +/** + * The icons of the pre-built screens, drawn from the path data of the Material icons, which are + * published under the Apache 2.0 license. They are declared here because the extended Material + * icons are not published for every Snitcher target. + */ +internal object SnitcherIcons { + + val Restore: ImageVector by lazy { + materialIcon( + name = "Restore", + pathData = "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 " + + "0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 " + + "3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z", + ) + } + + val Debug: ImageVector by lazy { + materialIcon( + name = "Debug", + pathData = "M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm-7 7H3v4c0 1.1.9 2 " + + "2 2h4v-2H5v-4zM5 5h4V3H5c-1.1 0-2 .9-2 2v4h2V5zm14-2h-4v2h4v4h2V5c0-1.1-.9-2-2-2zm0 " + + "16h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4z", + ) + } + + val Copy: ImageVector by lazy { + materialIcon( + name = "Copy", + pathData = + "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 " + + "0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z", + ) + } + + private fun materialIcon(name: String, pathData: String): ImageVector = ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).addPath( + pathData = PathParser().parsePathString(pathData).toNodes(), + fill = SolidColor(Color.Black), + ).build() +} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/SnitcherPrimaryButton.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/SnitcherPrimaryButton.kt similarity index 100% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/SnitcherPrimaryButton.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/SnitcherPrimaryButton.kt diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColor.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColor.kt similarity index 73% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColor.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColor.kt index 24ac20e..18a6de3 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColor.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColor.kt @@ -63,6 +63,36 @@ public data class SnitcherColor( background = Color.Black, textHighEmphasis = Color.White, ) + + /** + * Builds a palette out of ARGB values, such as `0xFF28A9F1`. + * + * [Color] is a Kotlin value class that Objective-C and Swift cannot construct, so this is the + * entry point for them. Pass [UNSPECIFIED] to let a color follow the one it belongs to. + * + * @return A [SnitcherColor] instance holding the given colors. + */ + public fun fromArgb( + primary: Long, + background: Long, + textHighEmphasis: Long, + onPrimary: Long = 0xFFFFFFFF, + textLowEmphasis: Long = UNSPECIFIED, + outline: Long = UNSPECIFIED, + ): SnitcherColor = SnitcherColor( + primary = Color(primary), + background = Color(background), + textHighEmphasis = Color(textHighEmphasis), + onPrimary = Color(onPrimary), + textLowEmphasis = textLowEmphasis.toColorOrUnspecified(), + outline = outline.toColorOrUnspecified(), + ) + + /** Marks a color of [fromArgb] as unspecified, so that it follows the color it belongs to. */ + public const val UNSPECIFIED: Long = -1L + + private fun Long.toColorOrUnspecified(): Color = + if (this == UNSPECIFIED) Color.Unspecified else Color(this) } } @@ -71,7 +101,6 @@ public data class SnitcherColor( * color, such as `SnitcherColor.defaultColors().copy(primary = Color.Red)`, keeps the palette * consistent. */ -@JvmSynthetic internal fun SnitcherColor.resolveUnspecified(): SnitcherColor = copy( textLowEmphasis = textLowEmphasis.takeOrElse { textHighEmphasis.copy(alpha = 0.6f) }, outline = outline.takeOrElse { primary }, diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherShapes.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherShapes.kt similarity index 78% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherShapes.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherShapes.kt index 65e09d0..bbea198 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherShapes.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherShapes.kt @@ -32,4 +32,14 @@ import com.skydoves.snitcher.ui.ExceptionTraceScreen public data class SnitcherShapes( val button: Shape = RoundedCornerShape(8.dp), val stacktrace: Shape = RoundedCornerShape(6.dp), -) +) { + public companion object { + /** + * Provides the default shapes of the Snitcher screens, which is also the entry point for + * Swift, where Kotlin default arguments are not available. + * + * @return A [SnitcherShapes] instance holding our shapes. + */ + public fun defaultShapes(): SnitcherShapes = SnitcherShapes() + } +} diff --git a/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.kt new file mode 100644 index 0000000..52ca32a --- /dev/null +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.kt @@ -0,0 +1,26 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Adjusts the system bars to the given [color], which is the color drawn behind them by the + * Snitcher screens. Platforms without system bars do nothing. + */ +@Composable +internal expect fun SnitcherSystemBars(color: Color = SnitcherTheme.colors.background) diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTheme.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTheme.kt similarity index 90% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTheme.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTheme.kt index e077b38..f4c6d56 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTheme.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTheme.kt @@ -26,19 +26,20 @@ import com.skydoves.snitcher.ui.ExceptionTraceScreen /** * Local providers for various properties we connect to our components, for styling. + * + * They fall back to the installed [Snitcher.theme] so that a Snitcher screen also renders outside + * of a [SnitcherTheme] block, rather than throwing. */ private val LocalColors = compositionLocalOf { - error("No colors provided! Make sure to wrap all usages of Snitcher components in SnitcherTheme.") + Snitcher.theme.lightColors.resolveUnspecified() } private val LocalTypography = compositionLocalOf { - error( - "No typography provided! Make sure to wrap all usages of Snitcher components in SnitcherTheme.", - ) + Snitcher.theme.typography } private val LocalShapes = compositionLocalOf { - error("No shapes provided! Make sure to wrap all usages of Snitcher components in SnitcherTheme.") + Snitcher.theme.shapes } /** diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherThemeConfig.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherThemeConfig.kt similarity index 64% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherThemeConfig.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherThemeConfig.kt index b30a614..0d8a34f 100644 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherThemeConfig.kt +++ b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherThemeConfig.kt @@ -17,12 +17,11 @@ package com.skydoves.snitcher.ui.theme import androidx.compose.runtime.Immutable import com.skydoves.snitcher.Snitcher -import com.skydoves.snitcher.ui.ExceptionTraceActivity /** * A theme configuration for the pre-built Snitcher screens. Give an instance of this class to the - * `theme` parameter of [Snitcher.install], and the built-in [ExceptionTraceActivity] and every - * screen wrapped in [SnitcherTheme] will be styled with it. + * `theme` parameter of the platform installer, and the pre-built screens, as well as every screen + * wrapped in [SnitcherTheme], will be styled with it. * * ```kotlin * Snitcher.install( @@ -44,5 +43,24 @@ public data class SnitcherThemeConfig( val lightColors: SnitcherColor = SnitcherColor.defaultColors(), val darkColors: SnitcherColor = SnitcherColor.defaultDarkColors(), val typography: SnitcherTypography = SnitcherTypography.defaultTypography(), - val shapes: SnitcherShapes = SnitcherShapes(), + val shapes: SnitcherShapes = SnitcherShapes.defaultShapes(), +) { + public companion object { + /** + * Provides the default theme configuration, which is also the entry point for Swift, where + * Kotlin default arguments are not available. + * + * @return A [SnitcherThemeConfig] instance holding our defaults. + */ + public fun defaultThemeConfig(): SnitcherThemeConfig = SnitcherThemeConfig() + } +} + +/** + * Resolves the colors that were left unspecified, so that a palette handed to the screens, or read + * back from [Snitcher.theme], always carries concrete colors. + */ +internal fun SnitcherThemeConfig.resolved(): SnitcherThemeConfig = copy( + lightColors = lightColors.resolveUnspecified(), + darkColors = darkColors.resolveUnspecified(), ) diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTypography.kt b/snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTypography.kt similarity index 100% rename from snitcher/src/main/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTypography.kt rename to snitcher/src/commonMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherTypography.kt diff --git a/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/internal/ThrowableExtensionsTest.kt b/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/internal/ThrowableExtensionsTest.kt new file mode 100644 index 0000000..13652fe --- /dev/null +++ b/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/internal/ThrowableExtensionsTest.kt @@ -0,0 +1,43 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class ThrowableExtensionsTest { + + @Test + fun capturesTheExceptionNameMessageAndTrace() { + val throwable = IllegalStateException("something went wrong") + + val exception = throwable.toSnitcherException() + + assertEquals("IllegalStateException", exception.packageName) + assertEquals("something went wrong", exception.message) + assertTrue(exception.stackTrace.contains("something went wrong")) + assertTrue(exception.threadName.isNotEmpty()) + } + + @Test + fun capturesAnEmptyMessageWhenThereIsNone() { + val exception = RuntimeException().toSnitcherException() + + assertEquals("", exception.message) + assertEquals("RuntimeException", exception.packageName) + } +} diff --git a/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/storage/SnitcherStoreTest.kt b/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/storage/SnitcherStoreTest.kt new file mode 100644 index 0000000..58efe50 --- /dev/null +++ b/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/storage/SnitcherStoreTest.kt @@ -0,0 +1,88 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.storage + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.model.SnitcherPreference +import okio.FileSystem +import kotlin.random.Random +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class SnitcherStoreTest { + + private val path = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / + "snitcher-test-${Random.nextLong()}" / "crash.json" + private val store = SnitcherStore(FileSystem.SYSTEM, path) + + @AfterTest + fun tearDown() { + store.clear() + } + + @Test + fun readsBackWhatItWrote() { + val preference = SnitcherPreference( + snitcherException = exception, + launcher = "com.skydoves.snitcherdemo.MainActivity", + ) + + store.write(preference) + + assertEquals(preference, store.read()) + } + + @Test + fun readsNullWhenNothingWasWritten() { + assertNull(store.read()) + } + + @Test + fun readsNullAndForgetsACorruptFile() { + store.write(SnitcherPreference(snitcherException = exception, launcher = null)) + path.parent?.let { FileSystem.SYSTEM.createDirectories(it) } + FileSystem.SYSTEM.write(path) { writeUtf8("{ this is not a crash }") } + + assertNull(store.read()) + // a file that cannot be decoded would fail every launch, so reading it once removes it. + assertFalse(FileSystem.SYSTEM.exists(path)) + } + + @Test + fun reportsWhetherTheCrashReachedTheDisk() { + assertTrue(store.write(SnitcherPreference(snitcherException = exception, launcher = null))) + } + + @Test + fun readsNullAfterBeingCleared() { + store.write(SnitcherPreference(snitcherException = exception, launcher = null)) + store.clear() + + assertNull(store.read()) + } + + private val exception = SnitcherException( + threadId = 1L, + threadName = "main", + packageName = "RuntimeException", + message = "This is an intended runtime exception.", + stackTrace = "java.lang.RuntimeException: This is an intended runtime exception.", + ) +} diff --git a/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColorTest.kt b/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColorTest.kt new file mode 100644 index 0000000..f6d0018 --- /dev/null +++ b/snitcher/src/commonTest/kotlin/com/skydoves/snitcher/ui/theme/SnitcherColorTest.kt @@ -0,0 +1,57 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class SnitcherColorTest { + + @Test + fun buildsTheSameColorsFromArgbValues() { + val colors = SnitcherColor.fromArgb( + primary = 0xFF28A9F1, + background = 0xFFFFFFFF, + textHighEmphasis = 0xFF121212, + ) + + assertEquals(Color(0xFF28A9F1), colors.primary) + assertEquals(Color.White, colors.background) + assertEquals(Color(0xFF121212), colors.textHighEmphasis) + assertEquals(Color.White, colors.onPrimary) + } + + @Test + fun keepsUnspecifiedColorsUnspecified() { + val colors = SnitcherColor.fromArgb( + primary = 0xFF28A9F1, + background = 0xFFFFFFFF, + textHighEmphasis = 0xFF121212, + ) + + assertEquals(Color.Unspecified, colors.outline) + assertEquals(Color.Unspecified, colors.textLowEmphasis) + } + + @Test + fun resolvesUnspecifiedColorsToTheOnesTheyFollow() { + val colors = SnitcherColor.defaultColors().copy(primary = Color.Red).resolveUnspecified() + + assertEquals(Color.Red, colors.outline) + assertEquals(colors.textHighEmphasis.copy(alpha = 0.6f), colors.textLowEmphasis) + } +} diff --git a/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/Snitcher.desktop.kt b/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/Snitcher.desktop.kt new file mode 100644 index 0000000..c2a4fe7 --- /dev/null +++ b/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/Snitcher.desktop.kt @@ -0,0 +1,92 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.storage.SNITCHER_STORE_FILE_NAME +import com.skydoves.snitcher.storage.SnitcherStore +import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig +import okio.FileSystem +import okio.Path.Companion.toPath + +/** + * Installs Snitcher as the default uncaught exception handler of the JVM, which captures every + * exception that is not handled by the application and persists it before the handler returns. + * + * Unlike Android, the desktop JVM keeps running after an uncaught exception on a background thread, + * so the captured crash is published through [Snitcher.exception] right away and you can render it + * with `SnitcherTraceWindow`. + * + * @param storageDirectory The directory that holds the persisted crash. + * @param theme The theme that styles the pre-built screens. + * @param strings The texts of the pre-built screens. + * @param platformInfo The application and system description shown under the exception message. + * @param isDebuggable Whether the full trace screen is displayed instead of the restore screen. + * @param exceptionHandler An extra handler, such as a logger, which is called with the captured + * exception when the application crashes. + */ +public fun Snitcher.install( + storageDirectory: String = defaultStorageDirectory(), + theme: SnitcherThemeConfig = SnitcherThemeConfig(), + strings: SnitcherStrings = SnitcherStrings(), + platformInfo: String = defaultPlatformInfo(), + isDebuggable: Boolean = true, + exceptionHandler: (SnitcherException) -> Unit = {}, +) { + this.theme = theme + this.strings = strings + this.platformInfo = platformInfo + this.isDebuggable = isDebuggable + + bind( + store = SnitcherStore( + FileSystem.SYSTEM, + storageDirectory.toPath().resolve(SNITCHER_STORE_FILE_NAME), + ), + launcher = null, + exceptionHandler = exceptionHandler, + ) + + val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + if (isRethrowing) { + // the debug action threw the recorded crash again, so the record stays as it is. + isRethrowing = false + } else { + capture(throwable = throwable, launcher = null) + } + + // a JVM without a default handler prints the crash itself, and installing one silences that, + // so the trace keeps reaching the console either way. + val handler = defaultExceptionHandler + if (handler != null) { + handler.uncaughtException(thread, throwable) + } else { + throwable.printStackTrace() + } + } +} + +private fun defaultStorageDirectory(): String = System.getProperty("user.home") + "/.snitcher" + +private fun defaultPlatformInfo(): String = buildString { + append(System.getProperty("os.name").orEmpty()) + append(" ") + append(System.getProperty("os.version").orEmpty()) + append(" (JVM ") + append(System.getProperty("java.version").orEmpty()) + append(")") +} diff --git a/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/ui/SnitcherTraceWindow.kt b/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/ui/SnitcherTraceWindow.kt new file mode 100644 index 0000000..b891b41 --- /dev/null +++ b/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/ui/SnitcherTraceWindow.kt @@ -0,0 +1,60 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.rememberWindowState +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.debug +import com.skydoves.snitcher.ui.theme.SnitcherTheme + +/** + * Opens a window with the most recent crash whenever there is one, and closes it when the crash is + * dismissed. Place it inside your `application { }` block, next to your own window. + * + * @param onCloseRequest Called when the window is closed. It clears the persisted crash by default. + * @param title The title of the window. + */ +@Composable +public fun ApplicationScope.SnitcherTraceWindow( + onCloseRequest: () -> Unit = { Snitcher.clear() }, + title: String = "Crash report", +) { + val exception by Snitcher.exception.collectAsState() + val snitcherException = exception ?: return + + Window( + onCloseRequest = onCloseRequest, + state = rememberWindowState(), + title = title, + ) { + SnitcherTheme { + if (Snitcher.isDebuggable) { + ExceptionTraceScreen( + snitcherException = snitcherException, + onRestore = onCloseRequest, + onDebug = { Snitcher.debug(snitcherException) }, + ) + } else { + AppRestoreScreen(onRestore = onCloseRequest) + } + } + } +} diff --git a/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.desktop.kt b/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.desktop.kt new file mode 100644 index 0000000..09e70a7 --- /dev/null +++ b/snitcher/src/desktopMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.desktop.kt @@ -0,0 +1,23 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** Desktop windows have no system bars, so there is nothing to adjust. */ +@Composable +internal actual fun SnitcherSystemBars(color: Color) = Unit diff --git a/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/Snitcher.ios.kt b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/Snitcher.ios.kt new file mode 100644 index 0000000..efdb237 --- /dev/null +++ b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/Snitcher.ios.kt @@ -0,0 +1,198 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) + +package com.skydoves.snitcher + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.storage.SNITCHER_STORE_FILE_NAME +import com.skydoves.snitcher.storage.SnitcherStore +import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig +import kotlinx.cinterop.CFunction +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.invoke +import kotlinx.cinterop.staticCFunction +import okio.FileSystem +import okio.Path.Companion.toPath +import platform.Foundation.NSBundle +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSException +import platform.Foundation.NSGetUncaughtExceptionHandler +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSSetUncaughtExceptionHandler +import platform.Foundation.NSUserDomainMask +import platform.UIKit.UIDevice +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.Platform +import kotlin.native.setUnhandledExceptionHook +import kotlin.native.terminateWithUnhandledException + +/** + * Installs Snitcher on iOS, which records a crash and leaves the termination to the runtime. + * + * An unhandled Kotlin exception terminates the process, so the crash cannot be displayed while it + * happens. Snitcher records it synchronously, leaves the termination to the runtime exactly as it + * would happen without Snitcher, and publishes the crash through [Snitcher.exception] on the next + * launch, where you can present `snitcherViewController` or your own screen. + * + * @param storageDirectory The directory that holds the persisted crash. + * @param theme The theme that styles the pre-built screens. + * @param strings The texts of the pre-built screens. + * @param platformInfo The application and device description shown under the exception message. + * @param catchObjectiveCExceptions Whether an uncaught `NSException` raised by Objective-C or Swift + * should be recorded as well. Unix signals, such as the ones raised by a Swift runtime trap or by a + * memory error, are never recorded. + * @param exceptionHandler An extra handler, such as a logger, which is called with the captured + * exception when the application crashes. + */ +public fun Snitcher.install( + storageDirectory: String = defaultStorageDirectory(), + theme: SnitcherThemeConfig = SnitcherThemeConfig(), + strings: SnitcherStrings = SnitcherStrings(), + platformInfo: String = defaultPlatformInfo(), + catchObjectiveCExceptions: Boolean = true, + exceptionHandler: (SnitcherException) -> Unit = {}, +) { + this.theme = theme + this.strings = strings + this.platformInfo = platformInfo + this.isDebuggable = Platform.isDebugBinary + + bind( + store = SnitcherStore( + FileSystem.SYSTEM, + storageDirectory.toPath().resolve(SNITCHER_STORE_FILE_NAME), + ), + launcher = null, + exceptionHandler = exceptionHandler, + ) + + // installing the hook twice would chain Snitcher into itself and recurse until the stack dies, + // so the handlers are installed once and the previous ones are kept. + if (!hookInstalled) { + hookInstalled = true + previousHook = setUnhandledExceptionHook(snitcherHook) + } + + if (catchObjectiveCExceptions && !objcHandlerInstalled) { + objcHandlerInstalled = true + previousObjcHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler( + staticCFunction { exception -> + if (exception != null) { + Snitcher.capture(exception.toSnitcherException(), null) + } + previousObjcHandler?.let { handler -> + exception?.let { handler.invoke(it) } + } + }, + ) + } +} + +/** + * Installs Snitcher with the default configuration. + * + * Kotlin default arguments do not cross the Objective-C boundary, so this is the entry point to + * call from Swift when the defaults are enough: `Snitcher.shared.install()`. + */ +public fun Snitcher.install() { + install( + storageDirectory = defaultStorageDirectory(), + theme = SnitcherThemeConfig(), + strings = SnitcherStrings(), + platformInfo = defaultPlatformInfo(), + catchObjectiveCExceptions = true, + exceptionHandler = {}, + ) +} + +private val snitcherHook: (Throwable) -> Unit = { throwable -> + Snitcher.capture(throwable = throwable, launcher = null) + + val previous = previousHook + if (previous != null) { + previous.invoke(throwable) + } else { + // the runtime terminates the process and writes a crash report when no hook is installed, and + // recording a crash must not take that away. + terminateWithUnhandledException(throwable) + } +} + +private var hookInstalled = false +private var previousHook: ((Throwable) -> Unit)? = null +private var objcHandlerInstalled = false +private var previousObjcHandler: CPointer Unit>>? = null + +internal fun NSException.toSnitcherException(): SnitcherException { + // callStackSymbols is only filled in once the exception was raised, and Objective-C hands back a + // nil where Kotlin expects a list, so it has to be read as nullable. + val callStack: List<*>? = callStackSymbols + val symbols = callStack?.joinToString(separator = "\n") { it.toString() }.orEmpty() + val name = name.orEmpty() + val reason = reason.orEmpty() + return SnitcherException( + threadId = 0L, + threadName = "main", + packageName = name, + message = reason, + stackTrace = buildString { + append(name) + if (reason.isNotEmpty()) { + append(": ") + append(reason) + } + if (symbols.isNotEmpty()) { + append("\n") + append(symbols) + } + }, + ) +} + +private fun defaultStorageDirectory(): String { + val paths = NSSearchPathForDirectoriesInDomains( + directory = NSCachesDirectory, + domainMask = NSUserDomainMask, + expandTilde = true, + ) + return paths.firstOrNull() as? String ?: NSBundle.mainBundle.bundlePath +} + +private fun defaultPlatformInfo(): String { + val bundle = NSBundle.mainBundle + val version = bundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String + val build = bundle.objectForInfoDictionaryKey("CFBundleVersion") as? String + val device = UIDevice.currentDevice + return buildString { + if (version != null) { + append(version) + if (build != null) { + append(" (") + append(build) + append(")") + } + append(" ") + } + append(device.model) + append(" ") + append(device.systemName) + append(" ") + append(device.systemVersion) + } +} diff --git a/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/internal/Platform.ios.kt b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/internal/Platform.ios.kt new file mode 100644 index 0000000..f33e794 --- /dev/null +++ b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/internal/Platform.ios.kt @@ -0,0 +1,43 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.internal + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.model.SnitcherStackTraceElement +import platform.Foundation.NSThread + +internal actual fun currentThreadName(): String { + val thread = NSThread.currentThread + val name = thread.name + return when { + !name.isNullOrEmpty() -> name + thread.isMainThread -> "main" + else -> "unknown" + } +} + +/** Kotlin/Native does not expose a thread id, so the captured crash carries zero. */ +internal actual fun currentThreadId(): Long = 0L + +/** + * Kotlin/Native reports a stack trace as an array of strings rather than as structured frames, so + * the whole trace is kept in [SnitcherException.stackTrace] and this list stays empty. + */ +internal actual fun Throwable.snitcherStackTraceElements(): List = + emptyList() + +/** There are no structured frames to restore a throwable from on Kotlin/Native. */ +internal actual fun SnitcherException.restoreThrowable(): Throwable? = null diff --git a/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/ui/SnitcherViewController.kt b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/ui/SnitcherViewController.kt new file mode 100644 index 0000000..b39ccdb --- /dev/null +++ b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/ui/SnitcherViewController.kt @@ -0,0 +1,55 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.window.ComposeUIViewController +import com.skydoves.snitcher.Snitcher +import com.skydoves.snitcher.ui.theme.SnitcherTheme +import platform.UIKit.UIViewController + +/** + * Builds a [UIViewController] that renders the most recent crash, which you can present from Swift + * on the launch that follows a crash. + * + * ```swift + * if Snitcher.shared.exception.value != nil { + * present(SnitcherViewControllerKt.snitcherViewController(onRestore: { ... }), animated: true) + * } + * ``` + * + * @param onRestore Called when the restore button is tapped, which is where you dismiss the + * controller and reset your own state. The button is hidden when it is null. + */ +public fun snitcherViewController(onRestore: (() -> Unit)? = null): UIViewController = + ComposeUIViewController { + val exception by Snitcher.exception.collectAsState() + val snitcherException = exception + + SnitcherTheme { + if (snitcherException != null) { + if (Snitcher.isDebuggable) { + ExceptionTraceScreen( + snitcherException = snitcherException, + onRestore = onRestore, + ) + } else { + AppRestoreScreen(onRestore = onRestore) + } + } + } + } diff --git a/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.ios.kt b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.ios.kt new file mode 100644 index 0000000..a8a2aba --- /dev/null +++ b/snitcher/src/iosMain/kotlin/com/skydoves/snitcher/ui/theme/SnitcherSystemBars.ios.kt @@ -0,0 +1,26 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * iOS decides the status bar appearance from the view controller, so the Snitcher screens only take + * care of the safe area insets. + */ +@Composable +internal actual fun SnitcherSystemBars(color: Color) = Unit diff --git a/snitcher/src/iosTest/kotlin/com/skydoves/snitcher/SnitcherIosTest.kt b/snitcher/src/iosTest/kotlin/com/skydoves/snitcher/SnitcherIosTest.kt new file mode 100644 index 0000000..01e7387 --- /dev/null +++ b/snitcher/src/iosTest/kotlin/com/skydoves/snitcher/SnitcherIosTest.kt @@ -0,0 +1,117 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalNativeApi::class) + +package com.skydoves.snitcher + +import com.skydoves.snitcher.model.SnitcherException +import platform.Foundation.NSException +import platform.Foundation.NSTemporaryDirectory +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.getUnhandledExceptionHook +import kotlin.random.Random +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Covers the iOS installation, which is the only way an iOS application can record a crash before + * the runtime terminates the process. + * + * The hook itself is not invoked here on purpose. It ends in `terminateWithUnhandledException`, + * exactly as the runtime would without Snitcher, so running it would kill the test binary. + */ +internal class SnitcherIosTest { + + private var handled: SnitcherException? = null + + @BeforeTest + fun setUp() { + handled = null + Snitcher.install( + storageDirectory = NSTemporaryDirectory() + "snitcher-test-" + Random.nextLong(), + platformInfo = "test", + catchObjectiveCExceptions = false, + exceptionHandler = { handled = it }, + ) + } + + @AfterTest + fun tearDown() { + Snitcher.clear() + } + + @Test + fun installsTheUnhandledExceptionHook() { + assertNotNull(getUnhandledExceptionHook()) + } + + @Test + fun installingTwiceKeepsTheSameHook() { + val hook = assertNotNull(getUnhandledExceptionHook()) + + Snitcher.install( + storageDirectory = NSTemporaryDirectory() + "snitcher-test-" + Random.nextLong(), + catchObjectiveCExceptions = false, + ) + + // chaining Snitcher into itself would recurse until the stack dies. + assertSame(hook, getUnhandledExceptionHook()) + } + + @Test + fun capturesAKotlinException() { + Snitcher.capture(IllegalStateException("captured by the hook"), null) + + val exception = assertNotNull(Snitcher.exception.value) + assertEquals("IllegalStateException", exception.packageName) + assertEquals("captured by the hook", exception.message) + assertTrue(exception.stackTrace.contains("captured by the hook")) + } + + @Test + fun runsTheCustomExceptionHandler() { + Snitcher.capture(RuntimeException("handled"), null) + + assertEquals("handled", assertNotNull(handled).message) + } + + @Test + fun keepsTheCrashForTheNextLaunch() { + Snitcher.capture(RuntimeException("persisted"), null) + + // a new installation reads the crash back, which is what happens on the launch after a crash. + val storedException = assertNotNull(Snitcher.store?.read()?.snitcherException) + assertEquals("persisted", storedException.message) + } + + @Test + fun capturesAnObjectiveCException() { + val exception = NSException.exceptionWithName( + name = "RuntimeException", + reason = "raised by Objective-C", + userInfo = null, + ).toSnitcherException() + + assertEquals("RuntimeException", exception.packageName) + assertEquals("raised by Objective-C", exception.message) + assertTrue(exception.stackTrace.contains("RuntimeException")) + } +} diff --git a/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/Snitcher.jvm.kt b/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/Snitcher.jvm.kt new file mode 100644 index 0000000..ca0d2b7 --- /dev/null +++ b/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/Snitcher.jvm.kt @@ -0,0 +1,28 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.model.toThrowable + +/** + * Throws the captured exception again so that an attached debugger catches it, which is what the + * debug button of the trace screen does. + */ +public fun Snitcher.debug(exception: SnitcherException): Nothing { + isRethrowing = true + throw exception.toThrowable() +} diff --git a/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/internal/Platform.jvm.kt b/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/internal/Platform.jvm.kt new file mode 100644 index 0000000..f69bbbe --- /dev/null +++ b/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/internal/Platform.jvm.kt @@ -0,0 +1,37 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.internal + +import com.skydoves.snitcher.model.SnitcherException +import com.skydoves.snitcher.model.SnitcherStackTraceElement +import com.skydoves.snitcher.model.toThrowable + +internal actual fun currentThreadName(): String = Thread.currentThread().name + +@Suppress("DEPRECATION") +internal actual fun currentThreadId(): Long = Thread.currentThread().id + +internal actual fun Throwable.snitcherStackTraceElements(): List = + stackTrace.map { element -> + SnitcherStackTraceElement( + fileName = element.fileName.orEmpty(), + lineNumber = element.lineNumber, + className = element.className, + methodName = element.methodName, + ) + } + +internal actual fun SnitcherException.restoreThrowable(): Throwable? = toThrowable() diff --git a/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/model/SnitcherExceptions.kt b/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/model/SnitcherExceptions.kt new file mode 100644 index 0000000..a1c183f --- /dev/null +++ b/snitcher/src/jvmSharedMain/kotlin/com/skydoves/snitcher/model/SnitcherExceptions.kt @@ -0,0 +1,29 @@ +/* + * Designed and developed by 2023 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.skydoves.snitcher.model + +/** + * Rebuilds a [Throwable] out of the captured exception, including the stack trace frames, so that + * it can be reported to a crash reporter or thrown again for a debugger. + * + * Kotlin/Native does not expose structured stack trace frames, so this is only available on Android + * and on the desktop. + */ +public fun SnitcherException.toThrowable(): Throwable = Throwable(message).apply { + stackTrace = stackTraceElement.map { + StackTraceElement(it.className, it.methodName, it.fileName, it.lineNumber) + }.toTypedArray() +} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/Snitcher.kt b/snitcher/src/main/kotlin/com/skydoves/snitcher/Snitcher.kt deleted file mode 100644 index 26dcc71..0000000 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/Snitcher.kt +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Designed and developed by 2023 skydoves (Jaewoong Eum) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.skydoves.snitcher - -import android.app.Activity -import android.app.Application -import android.content.Intent -import android.content.pm.ApplicationInfo -import android.os.Bundle -import android.os.Process -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile -import com.skydoves.snitcher.Snitcher.Companion.install -import com.skydoves.snitcher.datastore.SnitcherDataStore -import com.skydoves.snitcher.datastore.SnitcherSerializer -import com.skydoves.snitcher.installer.SnitcherInstaller -import com.skydoves.snitcher.model.SnitcherException -import com.skydoves.snitcher.model.SnitcherPreference -import com.skydoves.snitcher.model.toSnitcherElement -import com.skydoves.snitcher.ui.ExceptionTraceActivity -import com.skydoves.snitcher.ui.theme.SnitcherThemeConfig -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import kotlin.reflect.KClass -import kotlin.system.exitProcess - -/** - * A universal exception handler captures all exceptions within the application and directs them to - * the exception tracing screen. This screen facilitates debugging error messages, and users - * can either restart the crashed app or share the log messages. - * - * To set up the exception handler, use the [install] method. - * - * @property application The [Application] class to be registered for tracing activity lifecycles. - * @property launcher A launcher that will be activated when you intend to restore from the exception tracing activity. - * @property traceActivityClass An activity that will be initiated upon encountering a crash in your app. - * @property traceStrategy The trace strategy determines the launch behaviors when your app experiences a crash. - * @property defaultExceptionHandler The default exception handler initially provided by the Android OS. - * @property exceptionHandler A custom exception handler to perform additional tasks in case of a crash. - */ -public class Snitcher( - private val application: Application, - private val launcher: String?, - private val traceActivityClass: KClass<*>, - private val traceStrategy: TraceStrategy, - private val defaultExceptionHandler: Thread.UncaughtExceptionHandler, - private val exceptionHandler: suspend (SnitcherException) -> Unit, -) : Thread.UncaughtExceptionHandler { - - private val dataStore: SnitcherDataStore = run { - val dataStore = DataStoreFactory.create(SnitcherSerializer()) { - application.dataStoreFile("proto_snitcher.pb") - } - SnitcherDataStore(dataStore) - } - - private var lastActivity: Activity? = null - private var activityCount = 0 - - init { - application.registerActivityLifecycleCallbacks( - object : Application.ActivityLifecycleCallbacks { - - override fun onActivityResumed(activity: Activity) = Unit - - override fun onActivityPaused(activity: Activity) = Unit - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - - override fun onActivityDestroyed(activity: Activity) = Unit - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - if (isExceptionActivity(activity)) { - return - } - - lastActivity = activity - } - - override fun onActivityStarted(activity: Activity) { - if (isExceptionActivity(activity)) { - return - } - activityCount++ - lastActivity = activity - } - - override fun onActivityStopped(activity: Activity) { - if (isExceptionActivity(activity)) { - return - } - activityCount-- - if (activityCount < 0) { - lastActivity = null - } - } - }, - ) - } - - private fun isExceptionActivity(activity: Activity) = activity is ExceptionTraceActivity - - override fun uncaughtException(thread: Thread, throwable: Throwable) { - val snitcherException = SnitcherException( - threadId = thread.id, - threadName = thread.name, - packageName = throwable::class.java.simpleName, - message = throwable.message.orEmpty(), - stackTrace = throwable.stackTraceToString(), - stackTraceElement = throwable.stackTrace.map { it.toSnitcherElement() }, - ) - - // launch the exception tracing activity. - lastActivity?.run { - val launcher = this@Snitcher.launcher ?: this.javaClass.name - - // runs the custom exception handler. - scope.launch { - dataStore.updateSnitcherException( - SnitcherPreference( - snitcherException = snitcherException, - launcher = launcher, - ), - ) - - // call the default exception handler for integrating with other libraries. - defaultExceptionHandler(thread, throwable) - - // trace exceptions and launch activity - if (traceStrategy == TraceStrategy.CO_WORK) { - exceptionHandler.invoke(snitcherException) - launchExceptionTracingActivity(this@run) - } else { - exceptionHandler.invoke(snitcherException) - } - - // kill the current process. - Process.killProcess(Process.myPid()) - exitProcess(10) - } - } ?: defaultExceptionHandler.uncaughtException(thread, throwable) - } - - private fun launchExceptionTracingActivity(activity: Activity) = activity.run { - val launcher = Class.forName(traceActivityClass.java.name) - - startActivity( - Intent().setClass(this, launcher).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - }, - ) - finish() - } - - private fun defaultExceptionHandler(thread: Thread, throwable: Throwable) { - try { - if (defaultExceptionHandler::class.java.canonicalName?.startsWith( - "com.android.internal.os", - ) != true && - defaultExceptionHandler::class.java.canonicalName?.startsWith( - "com.google.firebase.crashlytics", - ) != true - ) { - defaultExceptionHandler.uncaughtException(thread, throwable) - } - } catch (_: Exception) { - } - } - - public companion object { - - private val _exception: MutableStateFlow = MutableStateFlow(null) - - /** Represents the state flow of the most recent [SnitcherException] encountered by an application. */ - public val exception: StateFlow = _exception - - private val _launcher: MutableStateFlow = MutableStateFlow("") - - /* - * Depicts the state flow of the launcher package that will initiate when your application - * is recovered within the exception tracing activity. - */ - public val launcher: StateFlow = _launcher - - /** - * The theme that styles the pre-built Snitcher screens, such as [ExceptionTraceActivity]. - * It is given by the `theme` parameter of [install], and it can also be replaced at any time - * to restyle the screens on the fly. - */ - public var theme: SnitcherThemeConfig by mutableStateOf(SnitcherThemeConfig()) - - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - init { - scope.launch { - SnitcherInstaller.internalSnitcher.collectLatest { snitcher -> - snitcher?.dataStore?.data?.filterNotNull()?.collectLatest { - _exception.value = it.snitcherException - _launcher.value = it.launcher.orEmpty() - } - } - } - } - - /** - * Represents if [Snitcher] is already installed or not. Lets you know if the - * internal [Snitcher] instance is being used as the uncaught exception - * handler when true or if it is using the default one if false. - */ - public inline val isInstalled: Boolean - get() = SnitcherInstaller.isInstalled - - @PublishedApi - internal val Application.isDebuggableApp: Boolean - get() = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 - - @PublishedApi - internal var isDebuggable: Boolean = false - - /** - * Install [Snitcher] into the installer. - * - * @param application Application. - * @param traceActivity An Activity that will be launched when your app experiences a crash. - * @param traceStrategy The trace strategy determines the launch behaviors when your app experiences a crash. - * @param theme The theme that styles the pre-built exception tracing screens. - * @param exceptionHandler You can manage extra exception handlers, like logging exceptions on Firebase, by providing this lambda function here. - * This handler will be called with a given [SnitcherException] when your app encounters exceptions. - */ - public fun install( - application: Application, - traceActivity: KClass<*> = ExceptionTraceActivity::class, - traceStrategy: TraceStrategy = TraceStrategy.CO_WORK, - theme: SnitcherThemeConfig = SnitcherThemeConfig(), - exceptionHandler: (SnitcherException) -> Unit = {}, - ) { - val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() ?: return - this.theme = theme - SnitcherInstaller.install( - Snitcher( - application = application, - launcher = null, - exceptionHandler = exceptionHandler, - traceActivityClass = traceActivity, - traceStrategy = traceStrategy, - defaultExceptionHandler = defaultExceptionHandler, - ), - ) - isDebuggable = application.isDebuggableApp - } - - /** - * Install [Snitcher] into the installer. - * - * @param traceActivity An Activity that will be launched when your app experiences a crash. - * @param traceStrategy The trace strategy determines the launch behaviors when your app experiences a crash. - * @param launcher A launcher Activity that used to restore your application from a crash. - * @param theme The theme that styles the pre-built exception tracing screens. - * @param exceptionHandler You can manage extra exception handlers, like logging exceptions on Firebase, by providing this lambda function here. - * This handler will be called with a given [SnitcherException] when your app encounters exceptions. - */ - public inline fun install( - application: Application, - traceActivity: KClass<*> = ExceptionTraceActivity::class, - traceStrategy: TraceStrategy = TraceStrategy.CO_WORK, - launcher: KClass = T::class, - theme: SnitcherThemeConfig = SnitcherThemeConfig(), - noinline exceptionHandler: (SnitcherException) -> Unit = {}, - ) { - val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() ?: return - this.theme = theme - SnitcherInstaller.install( - Snitcher( - application = application, - launcher = launcher.java.name, - exceptionHandler = exceptionHandler, - traceActivityClass = traceActivity, - traceStrategy = traceStrategy, - defaultExceptionHandler = defaultExceptionHandler, - ), - ) - isDebuggable = application.isDebuggableApp - } - } -} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/datastore/SnitcherSerializer.kt b/snitcher/src/main/kotlin/com/skydoves/snitcher/datastore/SnitcherSerializer.kt deleted file mode 100644 index 508d7bd..0000000 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/datastore/SnitcherSerializer.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Designed and developed by 2023 skydoves (Jaewoong Eum) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:OptIn(ExperimentalSerializationApi::class) - -package com.skydoves.snitcher.datastore - -import androidx.datastore.core.Serializer -import com.skydoves.snitcher.model.SnitcherPreference -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.protobuf.ProtoBuf -import java.io.InputStream -import java.io.OutputStream - -internal class SnitcherSerializer : Serializer { - - override val defaultValue: SnitcherPreference? = null - - override suspend fun readFrom(input: InputStream): SnitcherPreference? { - val snitcherInput = input.readBytes() - if (snitcherInput.isEmpty()) return null - return ProtoBuf.decodeFromByteArray(SnitcherPreference.serializer(), snitcherInput) - } - - override suspend fun writeTo(t: SnitcherPreference?, output: OutputStream) { - val snitcherException = t ?: return - val byteArray = ProtoBuf.encodeToByteArray(SnitcherPreference.serializer(), snitcherException) - - @Suppress("BlockingMethodInNonBlockingContext") - output.write(byteArray) - } -} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/installer/SnitcherInstaller.kt b/snitcher/src/main/kotlin/com/skydoves/snitcher/installer/SnitcherInstaller.kt deleted file mode 100644 index 4658452..0000000 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/installer/SnitcherInstaller.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Designed and developed by 2023 skydoves (Jaewoong Eum) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.skydoves.snitcher.installer - -import android.annotation.SuppressLint -import android.util.Log -import com.skydoves.snitcher.Snitcher -import kotlinx.coroutines.flow.MutableStateFlow - -/** An internal installer for the [Snitcher]. */ -@PublishedApi -internal object SnitcherInstaller { - - /** - * Represents if [Snitcher] is already installed or not. Lets you know if the - * internal [Snitcher] instance is being used as the uncaught exception - * handler when true or if it is using the default one if false. - */ - @JvmStatic - @PublishedApi - internal var isInstalled: Boolean = false - private set - - /** [Snitcher] instance to be used. */ - @SuppressLint("StaticFieldLeak") - @Volatile - @PublishedApi - internal var internalSnitcher: MutableStateFlow = MutableStateFlow(null) - - /** Installs a new [Snitcher] instance to be used. */ - @JvmStatic - @PublishedApi - internal fun install(snitcher: Snitcher) { - synchronized(this) { - if (isInstalled) { - Log.e( - "log", - "The $internalSnitcher is already installed but you've tried to " + - "install a new Snitcher: $snitcher", - ) - } - Thread.setDefaultUncaughtExceptionHandler(snitcher) - internalSnitcher.value = snitcher - isInstalled = true - } - } -} diff --git a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/ExceptionTraceScreen.kt b/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/ExceptionTraceScreen.kt deleted file mode 100644 index 832a089..0000000 --- a/snitcher/src/main/kotlin/com/skydoves/snitcher/ui/ExceptionTraceScreen.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Designed and developed by 2023 skydoves (Jaewoong Eum) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.skydoves.snitcher.ui - -import android.content.ClipData -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK -import android.os.Build -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CenterFocusStrong -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.skydoves.snitcher.R -import com.skydoves.snitcher.extensions.findActivity -import com.skydoves.snitcher.extensions.packageInfo -import com.skydoves.snitcher.extensions.toast -import com.skydoves.snitcher.extensions.versionCode -import com.skydoves.snitcher.model.SnitcherException -import com.skydoves.snitcher.ui.theme.SnitcherStatusBarColor -import com.skydoves.snitcher.ui.theme.SnitcherTheme -import kotlinx.coroutines.launch - -@Composable -public fun ExceptionTraceScreen(launcher: String, snitcherException: SnitcherException) { - SnitcherStatusBarColor() - - ExceptionTraceScreenContent( - packageName = launcher, - snitcherException = snitcherException, - ) -} - -@Composable -private fun ExceptionTraceScreenContent( - packageName: String, - snitcherException: SnitcherException, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val clipboard = LocalClipboard.current - val scrollState = rememberScrollState() - val packageInfo = remember { context.packageInfo() } - val stacktraceLabel = stringResource(id = R.string.snitcher_debug_crash_screen_stacktrace) - val copiedMessage = stringResource(id = R.string.snitcher_debug_crash_screen_copied) - - Column( - modifier = Modifier - .fillMaxSize() - .background(SnitcherTheme.colors.background) - .systemBarsPadding() - .verticalScroll(scrollState) - .padding(16.dp) - .testTag("exception_trace_screen"), - ) { - val title = remember(snitcherException) { - snitcherException.stackTrace.split(":").firstOrNull()?.split(".")?.last().orEmpty() - } - - Text( - text = title, - color = SnitcherTheme.colors.primary, - style = SnitcherTheme.typography.title, - ) - - Text( - modifier = Modifier.padding(vertical = 6.dp), - text = snitcherException.message, - color = SnitcherTheme.colors.textHighEmphasis, - style = SnitcherTheme.typography.message, - ) - - Text( - modifier = Modifier.padding(vertical = 6.dp), - text = "${packageInfo?.versionName} (${packageInfo?.versionCode()}) " + - "${Build.MANUFACTURER} ${Build.MODEL}", - color = SnitcherTheme.colors.textLowEmphasis, - style = SnitcherTheme.typography.deviceInfo, - ) - - SnitcherPrimaryButton( - modifier = Modifier.padding(vertical = 16.dp), - icon = Icons.Filled.RestartAlt, - text = stringResource(id = R.string.snitcher_debug_crash_screen_restore), - onClick = { - context.startActivity( - Intent().setClassName(context, packageName).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK - }, - ) - - val activity = context.findActivity() - activity?.finish() - }, - ) - - SnitcherPrimaryButton( - modifier = Modifier.padding(bottom = 16.dp), - icon = Icons.Filled.CenterFocusStrong, - text = stringResource(id = R.string.snitcher_debug_crash_screen_debug_on_ide), - onClick = { throw snitcherException.throwable }, - ) - - Box(modifier = Modifier.fillMaxWidth()) { - Text( - modifier = Modifier.align(Alignment.CenterStart), - text = stacktraceLabel, - color = SnitcherTheme.colors.primary, - style = SnitcherTheme.typography.sectionLabel, - ) - - Icon( - modifier = - Modifier - .align(Alignment.CenterEnd) - .clickable { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(stacktraceLabel, snitcherException.stackTrace)), - ) - context.toast(copiedMessage) - } - }, - imageVector = Icons.Filled.ContentCopy, - tint = SnitcherTheme.colors.textHighEmphasis, - contentDescription = "copy", - ) - } - - Spacer(modifier = Modifier.height(22.dp)) - - Text( - modifier = - Modifier - .border( - border = BorderStroke(2.dp, SnitcherTheme.colors.outline), - shape = SnitcherTheme.shapes.stacktrace, - ) - .padding(12.dp), - text = snitcherException.stackTrace, - color = SnitcherTheme.colors.textHighEmphasis, - style = SnitcherTheme.typography.stacktrace, - ) - } -} diff --git a/snitcher/src/main/res/values/strings.xml b/snitcher/src/main/res/values/strings.xml deleted file mode 100644 index e0736ae..0000000 --- a/snitcher/src/main/res/values/strings.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - Oops, Restore the previous screen? - The app crashed unexpectedly. We apologize for the inconvenience. Would you like to return to where you left off? - Restore - Restore App - Debug on IDE - Stacktrace - Copied! -