├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── henrikherzig │ │ └── playintegritychecker │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── henrikherzig │ │ │ └── playintegritychecker │ │ │ ├── MainActivity.kt │ │ │ ├── attestation │ │ │ ├── AttestationException.java │ │ │ ├── ExecutorRunner.kt │ │ │ ├── Requests.kt │ │ │ ├── Statement.kt │ │ │ ├── playintegrity │ │ │ │ ├── AttestationCallPlayIntegrity.kt │ │ │ │ └── PlayIntegrityStatement.kt │ │ │ └── safetynet │ │ │ │ ├── AttestationCallSafetyNet.kt │ │ │ │ ├── SafetyNetProcess.kt │ │ │ │ └── SafetyNetStatement.java │ │ │ └── ui │ │ │ ├── CustomButtonToggleGroup.kt │ │ │ ├── CustomCardComponents.kt │ │ │ ├── CustomCards.kt │ │ │ ├── CustomComponents.kt │ │ │ ├── CustomTextField.kt │ │ │ ├── CustomViewModel.kt │ │ │ ├── ResponseType.kt │ │ │ ├── ResultContent.kt │ │ │ ├── about │ │ │ └── AboutPage.kt │ │ │ ├── navigationbar │ │ │ ├── BottomNavItem.kt │ │ │ └── NavigationBarWrapper.kt │ │ │ ├── playintegrity │ │ │ └── PlayIntegrityPage.kt │ │ │ ├── safetynet │ │ │ └── SafetyNetPage.kt │ │ │ ├── settings │ │ │ └── SettingsPage.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── values-de-rDE │ │ └── strings.xml │ │ ├── values-ja │ │ └── strings.xml │ │ ├── values-pt-rBR │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── henrikherzig │ └── playintegritychecker │ └── ExampleUnitTest.kt ├── build.gradle ├── docs ├── aboutLight.png ├── googlePlay.png ├── icon.png ├── playIntegrityLight.png ├── safetyNetJsonDark.png └── settingsLight.png ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── privacyPolicy.md └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Apple finder and other 2 | .DS_Store 3 | /app/debug/output-metadata.json 4 | /app/release/output-metadata.json 5 | 6 | 7 | ### AndroidStudio ### 8 | # Covers files to be ignored for android development using Android Studio. 9 | 10 | # Built application files 11 | *.apk 12 | *.ap_ 13 | *.aab 14 | 15 | # Files for the ART/Dalvik VM 16 | *.dex 17 | 18 | # Java class files 19 | *.class 20 | 21 | # Generated files 22 | bin/ 23 | gen/ 24 | out/ 25 | 26 | # Gradle files 27 | .gradle 28 | .gradle/ 29 | build/ 30 | 31 | # Signing files 32 | .signing/ 33 | 34 | # Local configuration file (sdk path, etc) 35 | local.properties 36 | 37 | # Proguard folder generated by Eclipse 38 | proguard/ 39 | 40 | # Log Files 41 | *.log 42 | 43 | # Android Studio 44 | /*/build/ 45 | /*/local.properties 46 | /*/out 47 | /*/*/build 48 | /*/*/production 49 | captures/ 50 | .navigation/ 51 | *.ipr 52 | *~ 53 | *.swp 54 | 55 | # Keystore files 56 | *.jks 57 | *.keystore 58 | 59 | # Google Services (e.g. APIs or Firebase) 60 | # google-services.json 61 | 62 | # Android Patch 63 | gen-external-apklibs 64 | 65 | # External native build folder generated in Android Studio 2.2 and later 66 | .externalNativeBuild 67 | 68 | # NDK 69 | obj/ 70 | 71 | # IntelliJ IDEA 72 | *.iml 73 | *.iws 74 | /out/ 75 | 76 | # User-specific configurations 77 | .idea/caches/ 78 | .idea/libraries/ 79 | .idea/shelf/ 80 | .idea/workspace.xml 81 | .idea/tasks.xml 82 | .idea/.name 83 | .idea/compiler.xml 84 | .idea/copyright/profiles_settings.xml 85 | .idea/encodings.xml 86 | .idea/misc.xml 87 | .idea/modules.xml 88 | .idea/scopes/scope_settings.xml 89 | .idea/dictionaries 90 | .idea/vcs.xml 91 | .idea/jsLibraryMappings.xml 92 | .idea/datasources.xml 93 | .idea/dataSources.ids 94 | .idea/sqlDataSources.xml 95 | .idea/dynamic.xml 96 | .idea/uiDesigner.xml 97 | .idea/assetWizardSettings.xml 98 | .idea/gradle.xml 99 | .idea/jarRepositories.xml 100 | .idea/navEditor.xml 101 | 102 | # Legacy Eclipse project files 103 | .classpath 104 | .project 105 | .cproject 106 | .settings/ 107 | 108 | # Mobile Tools for Java (J2ME) 109 | .mtj.tmp/ 110 | 111 | # Package Files # 112 | *.war 113 | *.ear 114 | 115 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) 116 | hs_err_pid* 117 | 118 | ## Plugin-specific files: 119 | 120 | # mpeltonen/sbt-idea plugin 121 | .idea_modules/ 122 | 123 | # JIRA plugin 124 | atlassian-ide-plugin.xml 125 | 126 | # Mongo Explorer plugin 127 | .idea/mongoSettings.xml 128 | 129 | # Crashlytics plugin (for Android Studio and IntelliJ) 130 | com_crashlytics_export_strings.xml 131 | crashlytics.properties 132 | crashlytics-build.properties 133 | fabric.properties 134 | 135 | ### AndroidStudio Patch ### 136 | 137 | !/gradle/wrapper/gradle-wrapper.jar 138 | 139 | # End of https://www.toptal.com/developers/gitignore/api/androidstudio -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "java.configuration.updateBuildConfiguration": "interactive" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Henrik Herzig 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # SPIC - Simple Play Integrity Checker 4 |

5 | 6 | 7 | 8 | 9 | 10 |

11 | 12 | SPIC (short for **S**imple **P**lay **I**ntegrity **C**hecker) is a simple Android App that demonstrates the usage of the [PlayIntegrity API](https://developer.android.com/google/play/integrity) as well as the deprecated [SafetyNet Attestation API](https://developer.android.com/training/safetynet/attestation). 13 | 14 | [Get it on Google Play](https://play.google.com/store/apps/details?id=com.henrikherzig.playintegritychecker) 17 | 18 | 19 | # Usage 20 | The app sends a request to the Play Integrity API or SafetyNet Attestation API verifies the request locally on the Android Device or on a remote Server using the [Server Implementation](https://github.com/herzhenr/spic-server) (URL can be defined in settings) and shows the result of the verdict to the user. The Raw JSON result can also be viewed and copied to the clipboard. 21 | 22 | Before calling the API to do the attestation a nonce has to be generated which can happen on the device locally (only recommended for testing purposes) or on the server implementation (recommended for production). When using the PlayIntegrity API the verdict check can also be toggled between google and server. The only difference is when using the server the response encryption keys are managed locally on the server and the decryption of the verdict takes place here whereas when using the google option they are managed by google directly and the verdict is decrypted there. 23 | 24 | On the settings page a url to reach the Server Implementation can be set. Toggeling between System/Light/Dark mode of the app can be done here as well. The about page contains some more resources about the used APIs as well as the App itself. 25 | 26 | # Disclaimer 27 | If you plan on using the Play Integrity / SafetyNet Attestation API in your own app, you should propably use a encrypted connection between the server and the client. Local checks on the Android Devices shouldn't be implemented either. Ideally you should pair this API with another authentication method. Be warned: This implementation is just a proof of concept! 28 | 29 | # Screenshots 30 | 31 | Play Integrity Request | supports Dark Mode 32 | :-------------------------:|:-------------------------: 33 | ![play_integrity](docs/playIntegrityLight.png) | ![dark_mode](docs/safetyNetJsonDark.png) 34 | 35 | Settings Page | About Page 36 | :-------------------------:|:-------------------------: 37 | ![settings](docs/settingsLight.png) | ![about](docs/aboutLight.png) 38 | 39 | # Download 40 | 41 | - [Google Play](https://play.google.com/store/apps/details?id=com.henrikherzig.playintegritychecker) 42 | - [GitHub Releases](https://github.com/herzhenr/spic-android/releases) 43 | 44 | # Build 45 | 46 | - build the app using AndroidStudio or the `gradlew :app:aR` command 47 | - if the server functionality is desired, refer to the documentation of the [Server Implementation](https://github.com/herzhenr/spic-server) to set things like environment variables 48 | 49 | Some sepcific notes to use the SafetyNet and PlayIntegrity API: 50 | 51 | ## SafetyNet: 52 | - Obtain an API Key for the SafetyNet Attestation API from Google follwing the [official documentation](https://developer.android.com/training/safetynet/attestation#obtain-api-key) 53 | - add it to to `local.properties` as `api_key=...` 54 | 55 | ## PlayIntegrity: 56 | - in order to decrypt the integrity verdict locally on the device or the server you have to manage the corresponding decryption and verification keys by yourself. This is only possible if you have a paid Google Developer Account and registered the app bundle in the [Google Play Console](https://play.google.com/console/about/). Navigate to **Release** -> **Setup** -> **AppIntegrity** -> **Response encryption**, click on **Change** and choose **Manage and download my response encryption keys**. Follow the instructions to create a private-public key pair in order to download the encrypted keys. 57 | - add the keys to `local.properties` as `base64_of_encoded_decryption_key=...` and `base64_of_encoded_verification_key=...` 58 | 59 | Final local.properties file should look like this: 60 | ``` 61 | sdk.dir = YOUR SDK PATH 62 | api_key = YOUR KEY 63 | base64_of_encoded_decryption_key = YOUR DECRYPTION KEY 64 | base64_of_encoded_verification_key = YOUR VERIFICATION KEY 65 | ``` 66 | 67 | # Architecture 68 | The app is being developed with the help of Jetpack Compose and makes use of the material 2 design components of google. The following APIs are used: 69 | - SafetyNet Attestation API 70 | - Play Integrity API 71 | # Credits 72 | Some parts of the app are inspired and contain code of the [YASNAC](https://github.com/RikkaW/YASNAC) app implementation of the SafetyNet Attestation API by RikkaW. Especially the UI of the verdict result are inspired from this app. 73 | Some other parts of the server implementation (google request for playIntegrity decrypt) are inspired by the app [Play Integrity API Checker](https://github.com/1nikolas/play-integrity-checker-app) by 1nikolas. 74 | 75 | 76 | # License 77 | MIT License 78 | 79 | ``` 80 | Copyright (c) 2023 Henrik Herzig 81 | 82 | Permission is hereby granted, free of charge, to any person obtaining a copy 83 | of this software and associated documentation files (the "Software"), to deal 84 | in the Software without restriction, including without limitation the rights 85 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 86 | copies of the Software, and to permit persons to whom the Software is 87 | furnished to do so, subject to the following conditions: 88 | 89 | The above copyright notice and this permission notice shall be included in all 90 | copies or substantial portions of the Software. 91 | 92 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 93 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 94 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 95 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 96 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 97 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 98 | SOFTWARE. 99 | ``` -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | ./release/*.aab 3 | ./debug/*.aab 4 | *.aab -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'com.mikepenz.aboutlibraries.plugin' 5 | } 6 | 7 | Properties properties = new Properties() 8 | File localPropFile = project.rootProject.file("local.properties") 9 | if (localPropFile.exists()) { 10 | properties.load(localPropFile.newDataInputStream()) 11 | } 12 | 13 | android { 14 | compileSdk 33 15 | 16 | defaultConfig { 17 | applicationId "com.henrikherzig.playintegritychecker" 18 | minSdk 26 19 | targetSdk 33 20 | versionCode 7 21 | versionName "1.4.0" 22 | 23 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 24 | vectorDrawables { 25 | useSupportLibrary true 26 | } 27 | } 28 | 29 | buildTypes { 30 | debug { 31 | buildConfigField("String", "api_key", "\"" + properties['api_key'] + "\"") 32 | buildConfigField("String", "base64_of_encoded_decryption_key", "\"" + properties['base64_of_encoded_decryption_key'] + "\"") 33 | buildConfigField("String", "base64_of_encoded_verification_key", "\"" + properties['base64_of_encoded_verification_key'] + "\"") 34 | } 35 | release { 36 | minifyEnabled false 37 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 38 | buildConfigField("String", "api_key", "\"" + properties['api_key'] + "\"") 39 | buildConfigField("String", "base64_of_encoded_decryption_key", "\"" + properties['base64_of_encoded_decryption_key'] + "\"") 40 | buildConfigField("String", "base64_of_encoded_verification_key", "\"" + properties['base64_of_encoded_verification_key'] + "\"") 41 | } 42 | } 43 | compileOptions { 44 | sourceCompatibility JavaVersion.VERSION_1_8 45 | targetCompatibility JavaVersion.VERSION_1_8 46 | } 47 | kotlinOptions { 48 | jvmTarget = '1.8' 49 | } 50 | buildFeatures { 51 | compose true 52 | } 53 | composeOptions { 54 | kotlinCompilerExtensionVersion compose_version 55 | } 56 | packagingOptions { 57 | resources { 58 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 59 | } 60 | } 61 | } 62 | 63 | dependencies { 64 | 65 | implementation 'androidx.core:core-ktx:1.8.0' 66 | implementation "androidx.compose.ui:ui:$compose_version" 67 | implementation "androidx.compose.material:material:$compose_version" 68 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 69 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.0' 70 | implementation 'androidx.activity:activity-compose:1.5.0' 71 | implementation "androidx.compose.material:material-icons-extended:$compose_version" 72 | implementation 'androidx.browser:browser:1.4.0' 73 | 74 | implementation("com.google.android.play:integrity:1.0.1") 75 | implementation("com.google.android.gms:play-services-safetynet:18.0.1") 76 | implementation('com.google.http-client:google-http-client-android:1.42.0') { 77 | // otherwise conflicts, has to be excluded 78 | exclude group: 'org.apache.httpcomponents' 79 | } 80 | implementation("org.bitbucket.b_c:jose4j:0.7.12") 81 | implementation 'com.squareup.okhttp3:okhttp:4.10.0' 82 | implementation "androidx.navigation:navigation-compose:2.5.1" 83 | implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" 84 | implementation "androidx.datastore:datastore-preferences:1.0.0" 85 | implementation "androidx.compose.runtime:runtime-livedata:$compose_version" 86 | 87 | implementation 'com.google.code.gson:gson:2.9.1' 88 | 89 | implementation "com.google.accompanist:accompanist-pager:0.23.1" 90 | 91 | implementation "com.mikepenz:aboutlibraries-compose:10.5.2" 92 | 93 | testImplementation 'junit:junit:4.13.2' 94 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 95 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 96 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 97 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" 98 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" 99 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/henrikherzig/playintegritychecker/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.henrikherzig.playintegritychecker", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker 2 | 3 | 4 | import android.content.Context 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.activity.viewModels 9 | import androidx.datastore.preferences.preferencesDataStore 10 | import com.google.android.gms.common.GoogleApiAvailability 11 | import com.henrikherzig.playintegritychecker.attestation.playintegrity.AttestationCallPlayIntegrity 12 | import com.henrikherzig.playintegritychecker.attestation.safetynet.AttestationCallSafetyNet 13 | import com.henrikherzig.playintegritychecker.ui.navigationbar.BottomNavigationBar 14 | import com.henrikherzig.playintegritychecker.ui.theme.PlayIntegrityCheckerTheme 15 | 16 | // PreferenceDataStore for settings 17 | val Context.dataStore by preferencesDataStore("settings") 18 | 19 | class MainActivity : ComponentActivity() { 20 | 21 | private val viewSafetyNet: AttestationCallSafetyNet by viewModels() 22 | private val viewPlayIntegrity: AttestationCallPlayIntegrity by viewModels() 23 | 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | setContent { 27 | PlayIntegrityCheckerTheme() { 28 | // function to be called when safetyNetRequest is made 29 | val onSafetyNetRequest: (String, String, String?) -> Unit = 30 | { nonceGeneration: String, verifyType: String, url: String? -> 31 | viewSafetyNet.safetyNetAttestationRequest( 32 | this@MainActivity, 33 | nonceGeneration, 34 | verifyType, 35 | url 36 | ) 37 | } 38 | 39 | // function to be called when playIntegrityRequest is made 40 | val onPlayIntegrityRequest: (String, String, String?) -> Unit = 41 | { nonceGeneration: String, verifyType: String, url: String? -> 42 | viewPlayIntegrity.playIntegrityRequest( 43 | this@MainActivity, 44 | nonceGeneration, 45 | verifyType, 46 | url 47 | ) 48 | } 49 | 50 | // get play services Version 51 | val playServiceVersion = try { 52 | val packageInfo = packageManager.getPackageInfo( 53 | GoogleApiAvailability.GOOGLE_PLAY_SERVICES_PACKAGE, 54 | 0 55 | ) 56 | packageInfo.versionName 57 | } catch (e: Throwable) { 58 | null 59 | } 60 | 61 | // create MainUI of App 62 | BottomNavigationBar( 63 | viewSafetyNet.safetyNetResult, 64 | onSafetyNetRequest, 65 | viewPlayIntegrity.playIntegrityResult, 66 | onPlayIntegrityRequest, 67 | playServiceVersion 68 | ) 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/AttestationException.java: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation; 2 | 3 | import androidx.annotation.NonNull; 4 | 5 | public final class AttestationException extends Exception { 6 | 7 | @NonNull 8 | private final String message; 9 | 10 | public AttestationException(@NonNull String message) { 11 | super(message); 12 | this.message = message; 13 | } 14 | 15 | public AttestationException(@NonNull String message, @NonNull Throwable cause) { 16 | super(message, cause); 17 | this.message = message; 18 | } 19 | 20 | @NonNull 21 | @Override 22 | public String getMessage() { 23 | return message; 24 | } 25 | 26 | @NonNull 27 | @Override 28 | public synchronized Throwable fillInStackTrace() { 29 | return this; 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/ExecutorRunner.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import java.util.concurrent.Callable 6 | import java.util.concurrent.Executor 7 | import java.util.concurrent.Executors 8 | class ExecutorRunner { 9 | // create a new instance of Executor using any factory methods 10 | private val executor: Executor = Executors.newSingleThreadExecutor() 11 | // handler of UI thread 12 | private val handler = Handler(Looper.getMainLooper()) 13 | // callable to communicate the result back to UI 14 | interface Callback { 15 | fun onComplete(result: R) 16 | fun onError(e: Exception?) 17 | } 18 | fun execute(callable: Callable, callback: Callback) { 19 | executor.execute { 20 | val result: R 21 | try { 22 | // execute the callable or any tasks asynchronously 23 | result = callable.call() 24 | handler.post { 25 | // update the result back to UI 26 | callback.onComplete(result) 27 | } 28 | } catch (e: Exception) { 29 | e.printStackTrace() 30 | handler.post { // communicate error or handle 31 | callback.onError(e) 32 | } 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/Requests.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation 2 | 3 | import android.content.ContentValues 4 | import android.util.Log 5 | import okhttp3.OkHttpClient 6 | import okhttp3.Request 7 | import okhttp3.Response 8 | import okhttp3.ResponseBody 9 | import org.json.JSONObject 10 | 11 | /** 12 | * sends generic api call and checks for errors in the response 13 | */ 14 | fun getApiCall(apiUrl: String, entrypoint: String, query: String = ""): String { 15 | val client = OkHttpClient() 16 | val request: Request = Request.Builder() 17 | .get() 18 | .url("$apiUrl$entrypoint?$query") 19 | .build() 20 | 21 | val response: Response = client.newCall(request).execute() 22 | 23 | if (!response.isSuccessful) { 24 | val `object` = JSONObject(response.body!!.string()) 25 | val messageString = `object`.getString("Error") 26 | Log.d(ContentValues.TAG, "Error response from API Server. Message:\n'$messageString'") 27 | throw AttestationException("Error response from API Server. Message:\n'$messageString'") 28 | // return "Api request error. Code: " + response.code 29 | } 30 | val responseBody: ResponseBody? = response.body 31 | 32 | if (responseBody == null) { 33 | Log.d(ContentValues.TAG, "Error response from API Server (empty response) \n ${response.code}") 34 | throw AttestationException("Error response from API Server (empty response) \n ${response.code}") 35 | } 36 | 37 | return responseBody.string() 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/Statement.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation 2 | 3 | interface Statement { 4 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/playintegrity/AttestationCallPlayIntegrity.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation.playintegrity 2 | 3 | import android.content.ContentValues.TAG 4 | import android.content.Context 5 | import android.os.Handler 6 | import android.os.Looper 7 | import android.util.Base64 8 | import android.util.Log 9 | import androidx.compose.runtime.MutableState 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.lifecycle.ViewModel 12 | import androidx.lifecycle.viewModelScope 13 | import com.google.android.gms.tasks.Task 14 | import com.google.android.play.core.integrity.IntegrityManagerFactory 15 | import com.google.android.play.core.integrity.IntegrityTokenRequest 16 | import com.google.android.play.core.integrity.IntegrityTokenResponse 17 | import com.google.gson.Gson 18 | import com.henrikherzig.playintegritychecker.BuildConfig 19 | import com.henrikherzig.playintegritychecker.attestation.AttestationException 20 | import com.henrikherzig.playintegritychecker.attestation.PlayIntegrityStatement 21 | import com.henrikherzig.playintegritychecker.attestation.getApiCall 22 | import com.henrikherzig.playintegritychecker.ui.ResponseType 23 | import kotlinx.coroutines.CoroutineExceptionHandler 24 | import kotlinx.coroutines.Dispatchers 25 | import kotlinx.coroutines.launch 26 | import org.jose4j.jwe.JsonWebEncryption 27 | import org.jose4j.jws.JsonWebSignature 28 | import org.jose4j.jwx.JsonWebStructure 29 | import org.jose4j.lang.JoseException 30 | import org.json.JSONObject 31 | import java.security.KeyFactory 32 | import java.security.PublicKey 33 | import java.security.spec.X509EncodedKeySpec 34 | import java.util.Base64.getEncoder 35 | import java.util.concurrent.Executor 36 | import java.util.concurrent.Executors 37 | import javax.crypto.SecretKey 38 | import javax.crypto.spec.SecretKeySpec 39 | import kotlin.math.floor 40 | 41 | class AttestationCallPlayIntegrity : ViewModel() { 42 | 43 | val playIntegrityResult: MutableState> = 44 | mutableStateOf(ResponseType.None) 45 | 46 | fun playIntegrityRequest( 47 | context: Context, 48 | nonceGeneration: String, 49 | verifyType: String, 50 | url: String? 51 | ) = viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, _ -> }) { 52 | // set UI to loading (api calls etc. take some time...) 53 | playIntegrityResult.value = ResponseType.Loading 54 | 55 | // Generate nonce 56 | val nonce: String = try { 57 | generateNonce(nonceGeneration, url) 58 | } catch (e: Exception) { 59 | e.printStackTrace() 60 | // print received error message to the UI 61 | playIntegrityResult.value = ResponseType.Failure(e) 62 | return@launch 63 | } 64 | 65 | // Create an instance of a manager. 66 | val integrityManager = IntegrityManagerFactory.create(context) 67 | 68 | // Request the integrity token by providing a nonce. 69 | val integrityTokenResponse: Task = 70 | integrityManager.requestIntegrityToken( 71 | IntegrityTokenRequest.builder() 72 | .setNonce(nonce) 73 | // .setCloudProjectNumber(757430732184) // hardcoded for now 74 | .build() 75 | ) 76 | 77 | // do play integrity api call 78 | integrityTokenResponse.addOnSuccessListener { response -> 79 | run { 80 | // get token 81 | val integrityToken: String = response.token() 82 | // println(integrityToken) 83 | 84 | // show received token in UI 85 | // playIntegrityResult.value = ResponseType.SuccessSimple(integrityToken) 86 | 87 | // decode and verify 88 | try { 89 | decodeAndVerify(verifyType, integrityToken, nonceGeneration, url) 90 | } catch (e: JoseException) { 91 | Log.d(TAG, "can't decode Play Integrity response") 92 | e.printStackTrace() 93 | playIntegrityResult.value = 94 | ResponseType.Failure(Throwable("can't decode Play Integrity response")) 95 | return@run 96 | } 97 | } 98 | }.addOnFailureListener { e -> 99 | Log.d(TAG, "API Error, see Android UI for error message") 100 | playIntegrityResult.value = ResponseType.Failure(e) 101 | } 102 | } 103 | 104 | /** 105 | * generates a nonce. Depending on [nonceGeneration] this happens locally on the device or 106 | * remotely on a server with the url [apiServerUrl] 107 | */ 108 | private fun generateNonce(nonceGeneration: String, apiServerUrl: String?): String { 109 | val nonce: String = when (nonceGeneration) { 110 | // Generate nonce locally 111 | "local" -> { 112 | getNonceLocal(50) 113 | } 114 | // Receive nonce from the secure server 115 | "server" -> getNonceServer(apiServerUrl) 116 | // unknown nonceGeneration 117 | else -> throw (Throwable(message = "nonceGeneration '$nonceGeneration' is unknown")) 118 | } 119 | return nonce 120 | } 121 | 122 | private fun String.encode(): String { 123 | return Base64.encodeToString(this.toByteArray(charset("UTF-8")), Base64.URL_SAFE) 124 | } 125 | 126 | /** 127 | * generates a nonce locally 128 | */ 129 | private fun getNonceLocal(length: Int): String { 130 | var nonce = "" 131 | val allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 132 | for (i in 0 until length) { 133 | nonce += allowed[floor(Math.random() * allowed.length).toInt()].toString() 134 | } 135 | return nonce.encode() 136 | } 137 | 138 | /** 139 | * get nonce form secure server. sends a api request to the server to get the nonce 140 | */ 141 | private fun getNonceServer(url: String?): String { 142 | if (url == null || url == "") throw AttestationException("no url for server provided. check server url in settings") 143 | return getApiCall(url, "/api/playintegrity/nonce") 144 | } 145 | 146 | /** 147 | * 148 | */ 149 | private fun decodeAndVerify( 150 | verifyType: String, 151 | integrityToken: String, 152 | nonceGeneration: String, 153 | url: String? 154 | ) { 155 | when (verifyType) { 156 | "local" -> { 157 | // decrypt verdict locally and update UI 158 | val decoded: PlayIntegrityStatement = decodeLocally(integrityToken) 159 | playIntegrityResult.value = ResponseType.SuccessPlayIntegrity(decoded) 160 | } 161 | "google", "server" -> { 162 | // Create executor for async execution of API call 163 | val executor: Executor = Executors.newSingleThreadExecutor() 164 | val handler = Handler(Looper.getMainLooper()) 165 | executor.execute { 166 | try { 167 | val decoded = checkPlayIntegrityServer( 168 | integrityToken, 169 | verifyType, 170 | nonceGeneration, 171 | url 172 | ) 173 | // perform task asynchronously 174 | handler.post { 175 | playIntegrityResult.value = 176 | ResponseType.SuccessPlayIntegrity(decoded) 177 | } 178 | } catch (e: Exception) { 179 | e.printStackTrace() 180 | handler.post { 181 | playIntegrityResult.value = ResponseType.Failure(e) 182 | } 183 | } 184 | } 185 | } 186 | else -> { 187 | playIntegrityResult.value = 188 | ResponseType.Failure(Throwable(message = "verifyType '$verifyType' is unknown")) 189 | } 190 | } 191 | } 192 | 193 | /** 194 | * decodes the integrity verdict locally on the device for test purposes (not recommended for 195 | * production use) 196 | **/ 197 | private fun decodeLocally(integrityToken: String): PlayIntegrityStatement { 198 | val base64OfEncodedDecryptionKey = BuildConfig.base64_of_encoded_decryption_key 199 | 200 | // base64OfEncodedDecryptionKey is provided through Play Console. 201 | val decryptionKeyBytes: ByteArray = 202 | Base64.decode(base64OfEncodedDecryptionKey, Base64.DEFAULT) 203 | 204 | // Deserialized encryption (symmetric) key. 205 | val decryptionKey: SecretKey = SecretKeySpec( 206 | decryptionKeyBytes, 207 | /* offset= */ 0, 208 | decryptionKeyBytes.size, 209 | "AES" 210 | ) 211 | 212 | val base64OfEncodedVerificationKey =BuildConfig.base64_of_encoded_verification_key 213 | // base64OfEncodedVerificationKey is provided through Play Console. 214 | val encodedVerificationKey: ByteArray = 215 | Base64.decode(base64OfEncodedVerificationKey, Base64.DEFAULT) 216 | 217 | // Deserialized verification (public) key. 218 | val verificationKey: PublicKey = KeyFactory.getInstance("EC") 219 | .generatePublic(X509EncodedKeySpec(encodedVerificationKey)) 220 | 221 | 222 | val jwe: JsonWebEncryption = 223 | JsonWebStructure.fromCompactSerialization(integrityToken) as JsonWebEncryption 224 | jwe.key = decryptionKey 225 | 226 | // This also decrypts the JWE token. 227 | val compactJws = jwe.payload 228 | 229 | val jws: JsonWebSignature = 230 | JsonWebStructure.fromCompactSerialization(compactJws) as JsonWebSignature 231 | jws.key = verificationKey 232 | 233 | // This also verifies the signature. 234 | val payload: String = jws.payload 235 | println(payload) 236 | return Gson().fromJson(payload, PlayIntegrityStatement::class.java) 237 | } 238 | 239 | private fun checkPlayIntegrityServer( 240 | apiToken: String, 241 | verifyType: String, 242 | nonceGeneration: String, 243 | apiURL: String? 244 | ): PlayIntegrityStatement { 245 | // make api call 246 | if (apiURL == null) throw AttestationException("no url for server provided. check server url in settings") 247 | val response = getApiCall( 248 | apiURL, 249 | "/api/playintegrity/check", 250 | "token=$apiToken&mode=$verifyType&nonce=$nonceGeneration" 251 | ) 252 | 253 | val json= JSONObject(response) 254 | 255 | if (json.has("error")) { 256 | Log.d(TAG, "Api request error: " + json.getString("error")) 257 | throw AttestationException("Api request error: " + json.getString("error")) 258 | // return "Api request error: " + json.getString("error") 259 | } 260 | 261 | if (!json.has("deviceIntegrity")) { 262 | Log.d(TAG, "Api request error: Response does not contain deviceIntegrity") 263 | throw AttestationException("Api request error: Response does not contain deviceIntegrity") 264 | // return "Api request error: Response does not contain deviceIntegrity" 265 | } 266 | val jsonString = json.toString() 267 | return Gson().fromJson(jsonString, PlayIntegrityStatement::class.java) 268 | } 269 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/playintegrity/PlayIntegrityStatement.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation 2 | 3 | import com.google.api.client.util.Key 4 | 5 | /** 6 | * A statement returned by the Play Integrity API. 7 | */ 8 | class PlayIntegrityStatement : Statement { 9 | /** 10 | * Details about the request 11 | */ 12 | @Key 13 | val requestDetails: RequestDetails? = null 14 | 15 | /** 16 | * Details about the integrity of the app 17 | */ 18 | @Key 19 | val appIntegrity: AppIntegrity? = null 20 | 21 | /** 22 | * Details about the device integrity 23 | */ 24 | @Key 25 | val deviceIntegrity: DeviceIntegrity? = null 26 | 27 | /** 28 | * Details about the account (licensing) 29 | */ 30 | @Key 31 | val accountDetails: AccountDetails? = null 32 | 33 | /** 34 | * Details about the environment 35 | */ 36 | @Key 37 | val environmentDetails: EnvironmentDetails? = null 38 | } 39 | 40 | class RequestDetails { 41 | /** 42 | * Request package name of the APK that submitted this request. 43 | */ 44 | @Key 45 | val requestPackageName: String? = null 46 | 47 | /** 48 | * Timestamp of the request. 49 | * should be checked by server so request cant be collected by attacker and sent back to server 50 | * later on 51 | */ 52 | @Key 53 | val timestampMillis: Long? = null 54 | 55 | /** 56 | * Embedded nonce sent as part of the request. 57 | * should be unique for each request and server should check if nonce belongs to this request 58 | */ 59 | @Key 60 | val nonce: String? = null 61 | } 62 | 63 | class AppIntegrity { 64 | /** 65 | * general app recognition verdict. If this evaluates to 'UNEVALUATED', no further infos are 66 | * provided in the response, so other fields are empty 67 | */ 68 | @Key 69 | val appRecognitionVerdict: String? = null 70 | 71 | /** 72 | * Package name of the APK that submitted this request. 73 | * should be checked by the server if it matches [BuildConfig.APPLICATION_ID] 74 | */ 75 | @Key 76 | val packageName: String? = null 77 | 78 | /** 79 | * Digest of certificate of the APK that submitted this request. 80 | * cts profile match should be checked by server 81 | */ 82 | @Key 83 | val certificateSha256Digest: ArrayList = ArrayList() 84 | 85 | /** 86 | * versionCode of appIntegrity 87 | */ 88 | @Key 89 | val versionCode: String? = null 90 | } 91 | 92 | class DeviceIntegrity { 93 | /** 94 | * deviceRecognitionVerdict. Types of measurements that contributed to this response. 95 | */ 96 | @Key 97 | val deviceRecognitionVerdict: ArrayList = ArrayList() 98 | 99 | /** 100 | * Details about the recent device activity 101 | */ 102 | @Key 103 | val recentDeviceActivity: RecentDeviceActivity? = null 104 | } 105 | 106 | class RecentDeviceActivity { 107 | /** 108 | * deviceActivityLevel. Tells you how many times your app requested an integrity token 109 | * on a specific device in the last hour. 110 | */ 111 | @Key 112 | val deviceActivityLevel: String? = null 113 | } 114 | 115 | class AccountDetails { 116 | /** 117 | * licensing of the app (only through google play distributed apps are licensed here) 118 | */ 119 | @Key 120 | val appLicensingVerdict: String? = null 121 | } 122 | 123 | class EnvironmentDetails { 124 | /** 125 | * The environmentDetails field contains a single value, playProtectVerdict, that provides 126 | * information about Google Play Protect on the device. 127 | */ 128 | @Key 129 | val playProtectVerdict: String? = null 130 | } 131 | -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/safetynet/AttestationCallSafetyNet.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation.safetynet 2 | 3 | import android.content.ContentValues 4 | import android.content.Context 5 | import android.os.Handler 6 | import android.os.Looper 7 | import android.util.Log 8 | import androidx.compose.runtime.MutableState 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.viewModelScope 12 | import com.google.android.gms.common.ConnectionResult 13 | import com.google.android.gms.common.GoogleApiAvailability 14 | import com.google.android.gms.common.api.ApiException 15 | import com.google.android.gms.safetynet.SafetyNet 16 | import com.henrikherzig.playintegritychecker.BuildConfig 17 | import com.henrikherzig.playintegritychecker.attestation.AttestationException 18 | import com.henrikherzig.playintegritychecker.attestation.getApiCall 19 | import com.henrikherzig.playintegritychecker.ui.ResponseType 20 | import kotlinx.coroutines.CoroutineExceptionHandler 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.launch 23 | import org.json.JSONObject 24 | import java.time.Instant 25 | import java.util.* 26 | import java.util.concurrent.Executor 27 | import java.util.concurrent.Executors 28 | 29 | 30 | class AttestationCallSafetyNet : ViewModel() { 31 | 32 | val safetyNetResult: MutableState> = 33 | mutableStateOf(ResponseType.None) 34 | 35 | /** 36 | * API depricated 37 | * calls Googles Safety Net attestation API and process its contents 38 | */ 39 | fun safetyNetAttestationRequest( 40 | context: Context, 41 | nonceGeneration: String, 42 | verifyType: String, 43 | url: String? 44 | ) = 45 | viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, _ -> 46 | 47 | }) { 48 | // Set UI to loading screen 49 | safetyNetResult.value = ResponseType.Loading 50 | 51 | // Receive the nonce from the secure server. 52 | val nonce: String = try { 53 | generateNonce(nonceGeneration, url) 54 | } catch (e: Exception) { 55 | e.printStackTrace() 56 | // print received error message to the UI 57 | safetyNetResult.value = ResponseType.Failure(e) 58 | return@launch 59 | } 60 | 61 | val apikey = BuildConfig.api_key 62 | 63 | // check if Google Services are available 64 | if (GoogleApiAvailability.getInstance() 65 | .isGooglePlayServicesAvailable(context, 13000000) != 66 | ConnectionResult.SUCCESS 67 | ) { 68 | // if not, show error message 69 | println("ERROR: Outdated Google Play Services") 70 | safetyNetResult.value = ResponseType.Failure( 71 | AttestationException("GooglePlay Services Error: Outdated Google Play Services") 72 | ) 73 | return@launch 74 | // TODO Prompt user to update Google Play Services. 75 | } 76 | // The SafetyNet Attestation API is available after this check 77 | 78 | // actual call to attestation API 79 | SafetyNet.getClient(context.applicationContext).attest(nonce.toByteArray(), apikey) 80 | .addOnSuccessListener { 81 | // Indicates communication with the service was successful. 82 | try { 83 | val token = it.jwsResult 84 | ?: throw AttestationException("No token from SafetyNet Attestation Received") 85 | decodeAndVerify(verifyType, token, nonceGeneration, url, nonce) 86 | } catch (e: Exception) { 87 | Log.w(ContentValues.TAG, "Error in Offline Verify: ", e) 88 | safetyNetResult.value = ResponseType.Failure(e) 89 | } 90 | 91 | } 92 | .addOnFailureListener { e -> 93 | // An error occurred while communicating with the service. 94 | if (e is ApiException) { 95 | // An error with the Google Play services API contains some additional details. 96 | e.statusCode 97 | // You can retrieve the status code using the apiException.statusCode property. 98 | Log.d( 99 | ContentValues.TAG, 100 | "Error with Google Play Services API: " + e.message 101 | ) 102 | } else { 103 | // A different, unknown type of error occurred. 104 | Log.d(ContentValues.TAG, "Unknown Error Type: " + e.message) 105 | } 106 | safetyNetResult.value = ResponseType.Failure(e) 107 | } 108 | } 109 | 110 | 111 | /** 112 | * generates a nonce. Depending on [nonceGeneration] this happens locally on the device or 113 | * remotely on a server with the url [apiServerUrl] 114 | */ 115 | private fun generateNonce(nonceGeneration: String, apiServerUrl: String?): String { 116 | val nonce: String = when (nonceGeneration) { 117 | // Generate nonce locally 118 | "local" -> { 119 | getNonceLocal() 120 | } 121 | // Receive nonce from the secure server 122 | "server" -> getNonceServer(apiServerUrl) 123 | // unknown nonceGeneration 124 | else -> throw (Throwable(message = "nonceGeneration '$nonceGeneration' is unknown")) 125 | } 126 | return nonce 127 | } 128 | 129 | /** 130 | * generates a nonce locally 131 | */ 132 | private fun getNonceLocal(): String { 133 | return UUID.randomUUID().toString() 134 | } 135 | 136 | /** 137 | * get nonce form secure server. sends a api request to the server to get the nonce 138 | */ 139 | private fun getNonceServer(url: String?): String { 140 | if (url == null || url == "") throw AttestationException("no url for server provided. check server url in settings") 141 | return getApiCall(url, "/api/safetynet/nonce") 142 | } 143 | 144 | /** 145 | * 146 | */ 147 | private fun decodeAndVerify( 148 | verifyType: String, 149 | integrityToken: String, 150 | nonceGeneration: String, 151 | url: String?, 152 | nonce: String 153 | ) { 154 | when (verifyType) { 155 | "local" -> { 156 | // decrypt verdict locally and update UI 157 | val decoded: SafetyNetStatement = 158 | SafetyNetProcess.decode(integrityToken) 159 | 160 | // verify token 161 | // SafetyNetProcess.verify(decoded, nonce) 162 | 163 | safetyNetResult.value = ResponseType.SuccessSafetyNet(decoded) 164 | } 165 | "server" -> { 166 | // Create executor for async execution of API call 167 | val executor: Executor = Executors.newSingleThreadExecutor() 168 | val handler = Handler(Looper.getMainLooper()) 169 | executor.execute { 170 | try { 171 | val decoded = checkPlayIntegrityServer( 172 | integrityToken, 173 | verifyType, 174 | nonceGeneration, 175 | url 176 | ) 177 | // perform task asynchronously 178 | handler.post { 179 | safetyNetResult.value = 180 | ResponseType.SuccessSafetyNet(decoded) 181 | } 182 | } catch (e: Exception) { 183 | e.printStackTrace() 184 | handler.post { 185 | safetyNetResult.value = ResponseType.Failure(e) 186 | } 187 | } 188 | } 189 | } 190 | else -> { 191 | safetyNetResult.value = 192 | ResponseType.Failure(Throwable(message = "verifyType '$verifyType' is unknown")) 193 | } 194 | } 195 | } 196 | 197 | 198 | private fun checkPlayIntegrityServer( 199 | apiToken: String, 200 | verifyType: String, 201 | nonceGeneration: String, 202 | apiURL: String? 203 | ): SafetyNetStatement { 204 | // make api call 205 | if (apiURL == null) throw AttestationException("no url for server provided. check server url in settings") 206 | val response = getApiCall( 207 | apiURL, 208 | "/api/safetynet/check", 209 | "token=$apiToken&mode=$verifyType&nonce=$nonceGeneration" 210 | ) 211 | 212 | val json = JSONObject(response) 213 | 214 | val nonce = json.get("nonce").toString() 215 | val timestampMs = json.getLong("timestampMs") 216 | val apkPackageName = json.get("apkPackageName").toString() 217 | val apkCertificateDigestSha256Array = json.getJSONArray("apkCertificateDigestSha256") 218 | val apkCertificateDigestSha256 = 219 | arrayOfNulls(apkCertificateDigestSha256Array.length()) 220 | for (i in 0 until apkCertificateDigestSha256Array.length()) { 221 | apkCertificateDigestSha256[i] = apkCertificateDigestSha256Array.optString(i) 222 | } 223 | val apkDigestSha256 = json.get("apkDigestSha256").toString() 224 | val ctsProfileMatch: Boolean = json.getBoolean("ctsProfileMatch") 225 | val basicIntegrity: Boolean = json.getBoolean("basicIntegrity") 226 | val evaluationType = json.get("evaluationType").toString() 227 | 228 | //val jsonString = json.toString() 229 | return SafetyNetStatement( 230 | nonce, 231 | timestampMs, 232 | apkPackageName, 233 | apkCertificateDigestSha256, 234 | apkDigestSha256, 235 | ctsProfileMatch, 236 | basicIntegrity, 237 | evaluationType 238 | ) 239 | //return Gson().fromJson(jsonString, SafetyNetStatement::class.java) 240 | } 241 | 242 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/safetynet/SafetyNetProcess.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation.safetynet 2 | 3 | import androidx.compose.runtime.MutableState 4 | import com.henrikherzig.playintegritychecker.attestation.safetynet.SafetyNetStatement 5 | import com.google.api.client.extensions.android.json.AndroidJsonFactory 6 | import com.google.api.client.json.webtoken.JsonWebSignature 7 | import com.henrikherzig.playintegritychecker.BuildConfig 8 | import com.henrikherzig.playintegritychecker.attestation.AttestationException 9 | import com.henrikherzig.playintegritychecker.ui.ResponseType 10 | import java.io.IOException 11 | import java.security.GeneralSecurityException 12 | import java.security.cert.X509Certificate 13 | import java.time.Duration 14 | import java.time.Instant 15 | 16 | //import org.apache.http.conn.ssl.DefaultHostnameVerifier; 17 | object SafetyNetProcess { 18 | //private static final DefaultHostnameVerifier HOSTNAME_VERIFIER = new DefaultHostnameVerifier(); 19 | /** 20 | * Parses and verifies the API response. This should be done on a server in the future because 21 | * local checks can be exploited by APK repackaging but for demonstration purposes this can be 22 | * done locally (Good way to practically show how to bypass local checks) 23 | * @param attestationResponse response of the API call 24 | * @return processed API response 25 | */ 26 | fun decode(attestationResponse: String): SafetyNetStatement { 27 | // Parse JSON Web Signature format. 28 | val jws: JsonWebSignature = try { 29 | JsonWebSignature.parser(AndroidJsonFactory.getDefaultInstance()) 30 | .setPayloadClass(SafetyNetStatement::class.java).parse(attestationResponse) 31 | } catch (e: IOException) { 32 | System.err.println( 33 | "Failure: " + attestationResponse + " is not valid JWS " + 34 | "format." 35 | ) 36 | throw AttestationException( 37 | "Failure: " + attestationResponse + " is not valid JWS " + 38 | "format." 39 | ) 40 | } 41 | 42 | // Verify the signature of the JWS and retrieve the signature certificate. 43 | val cert: X509Certificate? 44 | try { 45 | cert = jws.verifySignature() 46 | if (cert == null) { 47 | System.err.println("Failure: Signature verification failed.") 48 | throw AttestationException("Failure: Signature verification failed.") 49 | } 50 | } catch (e: GeneralSecurityException) { 51 | System.err.println( 52 | "Failure: Error during cryptographic verification of the JWS signature." 53 | ) 54 | throw AttestationException("Failure: Error during cryptographic verification of the JWS signature.") 55 | } 56 | 57 | // TODO: Verify the hostname of the certificate. 58 | // if (!verifyHostname("attest.android.com", cert)) { 59 | // System.err.println("Failure: Certificate isn't issued for the hostname attest.android" + 60 | // ".com."); 61 | // return null; 62 | // } 63 | 64 | // Extract and use the payload data. 65 | return jws.payload as SafetyNetStatement 66 | } 67 | 68 | // /** 69 | // * Verifies that the certificate matches the specified hostname. 70 | // * Uses the {@link DefaultHostnameVerifier} from the Apache HttpClient library 71 | // * to confirm that the hostname matches the certificate. 72 | // * 73 | // * @param hostname 74 | // * @param leafCert 75 | // * @return 76 | // */ 77 | // private static boolean verifyHostname(String hostname, X509Certificate leafCert) { 78 | // try { 79 | // // Check that the hostname matches the certificate. This method throws an exception if 80 | // // the cert could not be verified. 81 | // HOSTNAME_VERIFIER.verify(hostname, leafCert); 82 | // return true; 83 | // } catch (SSLException e) { 84 | // e.printStackTrace(); 85 | // } 86 | // 87 | // return false; 88 | // } 89 | 90 | /** 91 | * validates the [decoded] integrity verdict locally on the device for test purposes (not recommended for 92 | * production use). [nonce] is the original nonce which got passes to the safetyNet API call 93 | * throws [AttestationException] if an error occurs 94 | **/ 95 | fun verify(decoded: SafetyNetStatement, nonce: String) { 96 | // do more validation 97 | // check if nonce is correct 98 | if (decoded.nonce != nonce) { 99 | throw AttestationException("Wrong nonce received") 100 | } 101 | 102 | // check how long the response took (timeout set to 10 seconds) 103 | val maxDuration = Duration.ofSeconds(10) 104 | val timestamp = Instant.ofEpochMilli(decoded.timestampMs) 105 | val timeout = timestamp.plus(maxDuration) 106 | // TODO somehow, the second check fails on real Galaxy S9 device, requestTimestamp is later than timestamp of response. for now, check has been removed 107 | if (timestamp.isAfter(timeout) /*|| timestamp.isBefore(requestTimestamp)*/) { 108 | throw AttestationException("Timeout") 109 | } 110 | 111 | // check cts profile match and verify ctsProfileMatches 112 | if (decoded.isCtsProfileMatch) { 113 | val ctsProfileMatches = arrayOf( 114 | "rnv+gyOF6I07XyGZzNfPXz5K9zqX5aEzChFdowrzLm0=", 115 | "a+yXX21Qt/feRZckl6bm1awqvzBPGOV9OUZ4HMDOUog=", 116 | "rJfDDAnNl4/Kq2MkpwESX519oXToUgpUnpPmDTQIq2M=", 117 | ) 118 | if (ctsProfileMatches.none { it in decoded.apkCertificateDigestSha256 }) { 119 | throw AttestationException("Wrong CertificateDigestSha256") 120 | } 121 | 122 | // verify package name 123 | if (BuildConfig.APPLICATION_ID != decoded.apkPackageName) { 124 | throw AttestationException("Wrong Package name") 125 | } 126 | } else { 127 | print("Warning: no cts profile match") 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/attestation/safetynet/SafetyNetStatement.java: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.attestation.safetynet; 2 | 3 | import androidx.annotation.Nullable; 4 | 5 | import com.google.api.client.json.webtoken.JsonWebSignature; 6 | import com.google.api.client.util.Key; 7 | import com.google.common.io.BaseEncoding; 8 | import com.henrikherzig.playintegritychecker.attestation.Statement; 9 | 10 | import java.nio.charset.StandardCharsets; 11 | import java.util.Arrays; 12 | import java.util.List; 13 | 14 | /** 15 | * A statement returned by the SafetyNet Attestation API. 16 | */ 17 | public class SafetyNetStatement extends JsonWebSignature.Payload implements Statement { 18 | /** 19 | * Embedded nonce sent as part of the request. 20 | * should be unique for each request and server should check if nonce belongs to this request 21 | */ 22 | @Key 23 | private String nonce; 24 | 25 | /** 26 | * Timestamp of the request. 27 | * should be checked by server so request cant be collected by attacker and sent back to server 28 | * later on 29 | */ 30 | @Key 31 | private long timestampMs; 32 | 33 | /** 34 | * Package name of the APK that submitted this request. 35 | * should be checked by the server if it matches [BuildConfig.APPLICATION_ID] 36 | */ 37 | @Key 38 | private String apkPackageName; 39 | 40 | /** 41 | * Digest of certificate of the APK that submitted this request. 42 | * cts profile match should be checked by server 43 | */ 44 | @Key 45 | private String[] apkCertificateDigestSha256; 46 | 47 | /** 48 | * Digest of the APK that submitted this request. 49 | */ 50 | @Key 51 | private String apkDigestSha256; 52 | 53 | /** 54 | * The device passed CTS and matches a known profile. 55 | */ 56 | @Key 57 | private boolean ctsProfileMatch; 58 | 59 | /** 60 | * The device has passed a basic integrity test, but the CTS profile could not be verified. 61 | */ 62 | @Key 63 | private boolean basicIntegrity; 64 | 65 | /** 66 | * Types of measurements that contributed to this response. 67 | */ 68 | @Key 69 | private String evaluationType; 70 | 71 | //TODO parse advice field, and error (and show it in error UI element) 72 | 73 | public SafetyNetStatement() { 74 | } 75 | 76 | public SafetyNetStatement(String nonce, long timestampMs, @Nullable String apkPackageName, String[] apkCertificateDigestSha256, @Nullable String apkDigestSha256, boolean ctsProfileMatch, boolean basicIntegrity, String evaluationType) { 77 | this.nonce = nonce; 78 | this.timestampMs = timestampMs; 79 | this.apkPackageName = apkPackageName; 80 | this.apkCertificateDigestSha256 = apkCertificateDigestSha256; 81 | this.apkDigestSha256 = apkDigestSha256; 82 | this.ctsProfileMatch = ctsProfileMatch; 83 | this.basicIntegrity = basicIntegrity; 84 | this.evaluationType = evaluationType; 85 | } 86 | 87 | public String getNonce() { 88 | return new String(BaseEncoding.base64().decode(nonce), StandardCharsets.UTF_8); 89 | } 90 | 91 | public long getTimestampMs() { 92 | return timestampMs; 93 | } 94 | 95 | public String getApkPackageName() { 96 | return String.valueOf(apkPackageName); 97 | } 98 | 99 | public String getApkDigestSha256() { 100 | return String.valueOf(apkDigestSha256); 101 | } 102 | 103 | public List getApkCertificateDigestSha256() { 104 | return Arrays.asList(apkCertificateDigestSha256); 105 | } 106 | 107 | public boolean isCtsProfileMatch() { 108 | return ctsProfileMatch; 109 | } 110 | 111 | public boolean hasBasicIntegrity() { 112 | return basicIntegrity; 113 | } 114 | 115 | public boolean hasBasicEvaluationType() { 116 | return evaluationType.contains("BASIC"); 117 | } 118 | 119 | public boolean hasHardwareBackedEvaluationType() { 120 | return evaluationType.contains("HARDWARE_BACKED"); 121 | } 122 | 123 | public String integrityType() { 124 | return evaluationType; 125 | } 126 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/CustomButtonToggleGroup.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.unit.Dp 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.zIndex 14 | 15 | 16 | /** 17 | * Custom MaterialButtonToggleGroup 18 | */ 19 | @OptIn(ExperimentalMaterialApi::class) 20 | @Composable 21 | fun ToggleGroup( 22 | selectedIndex: String, 23 | items: List>, 24 | indexChanged: (String) -> Unit, 25 | height: Dp 26 | ) { 27 | val cornerRadius = 8.dp 28 | Row { 29 | items.forEachIndexed { index, item -> 30 | CompositionLocalProvider( 31 | LocalMinimumTouchTargetEnforcement provides false, 32 | ) { 33 | OutlinedButton( 34 | contentPadding = PaddingValues(horizontal = 0.dp), 35 | modifier = when (index) { 36 | 0 -> 37 | Modifier 38 | .offset(0.dp, 0.dp) 39 | .zIndex(if (selectedIndex == item[0]) 1f else 0f) 40 | .fillMaxWidth().weight(1f) 41 | .height(height) 42 | else -> 43 | Modifier 44 | .offset((-1 * index).dp, 0.dp) 45 | .zIndex(if (selectedIndex == item[0]) 1f else 0f) 46 | .fillMaxWidth().weight(1f) 47 | .height(height) 48 | }, 49 | onClick = { 50 | indexChanged(item[0]) 51 | //selectedIndex = index 52 | }, 53 | shape = when (index) { 54 | // left outer button 55 | 0 -> RoundedCornerShape( 56 | topStart = cornerRadius, 57 | topEnd = 0.dp, 58 | bottomStart = cornerRadius, 59 | bottomEnd = 0.dp 60 | ) 61 | // right outer button 62 | items.size - 1 -> RoundedCornerShape( 63 | topStart = 0.dp, 64 | topEnd = cornerRadius, 65 | bottomStart = 0.dp, 66 | bottomEnd = cornerRadius 67 | ) 68 | // middle button 69 | else -> RoundedCornerShape( 70 | topStart = 0.dp, 71 | topEnd = 0.dp, 72 | bottomStart = 0.dp, 73 | bottomEnd = 0.dp 74 | ) 75 | }, 76 | border = BorderStroke( 77 | 1.dp, if (selectedIndex == item[0]) { 78 | MaterialTheme.colors.primary 79 | } else { 80 | Color.DarkGray.copy(alpha = 0.75f) 81 | } 82 | ), 83 | colors = if (selectedIndex == item[0]) { 84 | // selected colors 85 | ButtonDefaults.outlinedButtonColors( 86 | backgroundColor = MaterialTheme.colors.primary.copy( 87 | alpha = 0.1f 88 | ), contentColor = MaterialTheme.colors.primary 89 | ) 90 | } else { 91 | // not selected colors 92 | ButtonDefaults.outlinedButtonColors( 93 | backgroundColor = MaterialTheme.colors.surface, 94 | contentColor = MaterialTheme.colors.primary 95 | ) 96 | }, 97 | ) { 98 | Text( 99 | text = item[1], 100 | color = if (selectedIndex == items[index][0]) { 101 | MaterialTheme.colors.primary 102 | } else { 103 | Color.DarkGray.copy(alpha = 0.9f) 104 | }, 105 | ) 106 | } 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/CustomCardComponents.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.foundation.text.selection.SelectionContainer 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.outlined.* 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.vector.ImageVector 16 | import androidx.compose.ui.platform.LocalClipboardManager 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.text.AnnotatedString 19 | import androidx.compose.ui.text.font.FontFamily 20 | import androidx.compose.ui.text.font.FontWeight 21 | import androidx.compose.ui.tooling.preview.Preview 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.unit.sp 24 | import com.henrikherzig.playintegritychecker.R 25 | 26 | 27 | /** 28 | * Custom Card Element 29 | * In this case, it is being used as a wrapper for the API result, loading screen or error screen 30 | */ 31 | @Composable 32 | fun CustomCard(content: @Composable () -> Unit) { 33 | Card( 34 | //elevation = 0.dp, 35 | border = ButtonDefaults.outlinedBorder, 36 | modifier = Modifier 37 | .fillMaxWidth(), 38 | content = content, 39 | shape = RoundedCornerShape(10.dp), 40 | //backgroundColor = MaterialTheme.colors.primary 41 | ) 42 | } 43 | 44 | /** 45 | * Title Element within the Card 46 | */ 47 | @Composable 48 | fun CustomCardTitle(text: String) { 49 | Text( 50 | text = text, 51 | fontWeight = FontWeight.Normal, 52 | fontSize = 20.sp, 53 | modifier = Modifier.padding(bottom = 2.dp), 54 | color = MaterialTheme.colors.primary 55 | ) 56 | Spacer(modifier = Modifier.height(6.dp)) 57 | } 58 | 59 | /** 60 | * Title Element within the Card (one layer deeper than Title) 61 | */ 62 | @Composable 63 | fun CustomCardTitle2(text: String) { 64 | Text( 65 | text = text, 66 | fontWeight = FontWeight.Normal, 67 | fontSize = 16.sp, 68 | modifier = Modifier.padding(bottom = 2.dp), 69 | color = MaterialTheme.colors.primary 70 | ) 71 | Spacer(modifier = Modifier.height(3.dp)) 72 | } 73 | 74 | /** 75 | * Title Element within the Card (another layer deeper than title2) 76 | */ 77 | @Composable 78 | fun CustomCardTitle3(text: String) { 79 | Text( 80 | text = text, 81 | style = MaterialTheme.typography.caption, 82 | color = MaterialTheme.colors.primary 83 | ) 84 | } 85 | 86 | /** 87 | * Creates 2 Text Elements on top of each other which resemble the title and the corresponding content 88 | * @param text1 the title of the group 89 | * @param text2 the content of the group 90 | */ 91 | @Composable 92 | fun CustomCardGroup(text1: String, text2: String) { 93 | CustomCardTitle3(text1) 94 | //Spacer(modifier = Modifier.height(12.dp)) 95 | Text(text = text2) 96 | Spacer(modifier = Modifier.height(12.dp)) 97 | } 98 | 99 | /** 100 | * Similar to [CustomCardGroup] but the content is either true or false and is shown with a 101 | * icon next to it 102 | * @param text the title of the group 103 | * @param passed the content of the group (either passed or failed) 104 | */ 105 | @Composable 106 | fun CustomCardBool(text: String, passed: Boolean) { 107 | Text( 108 | text = text, 109 | style = MaterialTheme.typography.caption, 110 | color = MaterialTheme.colors.primary 111 | ) 112 | Spacer(modifier = Modifier.height(1.dp)) 113 | 114 | val result = 115 | if (passed) stringResource(R.string.sn_passed) else stringResource(R.string.sn_failed) 116 | val color = if (passed) MaterialTheme.colors.primary else MaterialTheme.colors.error 117 | val icon = if (passed) Icons.Outlined.CheckCircle else Icons.Outlined.Cancel 118 | 119 | Row( 120 | verticalAlignment = Alignment.CenterVertically 121 | ) { 122 | Icon( 123 | imageVector = icon, 124 | tint = color, 125 | contentDescription = null, 126 | modifier = Modifier.size(16.dp) 127 | ) 128 | Spacer(modifier = Modifier.width(4.dp)) 129 | Text( 130 | text = result, 131 | color = color 132 | ) 133 | } 134 | Spacer(modifier = Modifier.height(12.dp)) 135 | } 136 | 137 | /** 138 | * Similar to [CustomCardGroup] but the content is either true, false or unevaluated and is shown 139 | * with a icon next to it 140 | * @param text the title of the group 141 | * @param passed the content of the group (either passed or failed) 142 | */ 143 | @Composable 144 | fun CustomCardBoolHorizontal(text: String, passed: Boolean?) { 145 | Row(verticalAlignment = Alignment.CenterVertically) { 146 | val color = 147 | if (passed == true) MaterialTheme.colors.primary else if (passed == false) MaterialTheme.colors.error else Color.Gray 148 | val icon = if (passed == true) Icons.Outlined.CheckCircle else Icons.Outlined.Cancel 149 | 150 | Icon( 151 | imageVector = icon, 152 | tint = color, 153 | contentDescription = null, 154 | modifier = Modifier.size(16.dp) 155 | ) 156 | Spacer(modifier = Modifier.width(4.dp)) 157 | Text( 158 | text = text, 159 | color = color 160 | ) 161 | Spacer(modifier = Modifier.height(12.dp)) 162 | } 163 | } 164 | 165 | /** 166 | * Custom button element 167 | */ 168 | @Composable 169 | fun CustomButton(onClick: () -> Unit, icon: ImageVector, text: String) { 170 | OutlinedButton( 171 | onClick = onClick, 172 | //shape = MaterialTheme.shapes.small, 173 | shape = RoundedCornerShape(10.dp), 174 | modifier = Modifier 175 | .fillMaxWidth() 176 | .height(40.dp), 177 | //enabled = enabled 178 | ) { 179 | Icon( 180 | icon, 181 | contentDescription = null, 182 | modifier = Modifier.size(ButtonDefaults.IconSize) 183 | ) 184 | Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 185 | Text(text = text) 186 | } 187 | } 188 | 189 | /** 190 | * shows a alert dialog with a title on top and a ok and copy button in the bottom which has the 191 | * ability to copy the text to the clipboard of the device 192 | * @param titleString Title Text to be displayed 193 | * @param content Text which is being shown within the alert dialog body 194 | * @param opened controls weather the dialog is opened or not 195 | */ 196 | @Composable 197 | fun CustomContentAlertDialog( 198 | modifier: Modifier = Modifier, 199 | titleString: String, 200 | content: @Composable (() -> Unit)? = null, 201 | opened: MutableState, 202 | ) { 203 | // val localClipboardManager = LocalClipboardManager.current 204 | CustomAlertDialog( 205 | modifier= modifier, 206 | titleString = titleString, 207 | titleIcon = Icons.Outlined.Help, 208 | opened = opened, 209 | content = content, 210 | ) 211 | } 212 | 213 | /** 214 | * shows a alert dialog with a title on top and a ok and copy button in the bottom which has the 215 | * ability to copy the text to the clipboard of the device 216 | * @param content Text which is being shown within the alert dialog body 217 | * @param opened controls weather the dialog is opened or not 218 | */ 219 | @Composable 220 | fun CustomCodeAlertDialog( 221 | content: String, 222 | opened: MutableState, 223 | ) { 224 | val localClipboardManager = LocalClipboardManager.current 225 | CustomAlertDialog( 226 | titleString = stringResource(id = R.string.dialog_title), 227 | titleIcon = Icons.Outlined.Code, 228 | opened = opened, 229 | content = { 230 | SelectionContainer { 231 | Text( 232 | text = content, 233 | fontFamily = FontFamily.Monospace, 234 | modifier = Modifier.verticalScroll(rememberScrollState()) 235 | ) 236 | } 237 | }, 238 | dismissButton = { 239 | TextButton( 240 | onClick = { localClipboardManager.setText(AnnotatedString(content)) }) { 241 | Text(stringResource(id = android.R.string.copy)) 242 | } 243 | }, 244 | ) 245 | } 246 | 247 | 248 | /** 249 | * shows a alert dialog with a title on top and a ok button in the bottom 250 | * @param titleString Title Text to be displayed 251 | * @param content Text which is being shown within the alert dialog body 252 | * @param opened controls weather the dialog is opened or not 253 | */ 254 | @Composable 255 | fun CustomTextAlertDialog( 256 | titleString: String, 257 | content: String, 258 | opened: MutableState, 259 | ) { 260 | CustomAlertDialog( 261 | titleString = titleString, 262 | titleIcon = Icons.Outlined.Help, 263 | opened = opened, 264 | content = { 265 | SelectionContainer { 266 | Text( 267 | text = content, 268 | modifier = Modifier.verticalScroll(rememberScrollState()) 269 | ) 270 | } 271 | }, 272 | ) 273 | } 274 | 275 | /** 276 | * custom alert dialog which is the foundation for other custom alert dialogs 277 | * @param titleString Title Text to be displayed 278 | * @param titleIcon Title icon to be displayed 279 | * @param content Content which is being shown within the alert dialog body 280 | * @param opened controls weather the dialog is opened or not 281 | */ 282 | @Composable 283 | fun CustomAlertDialog( 284 | modifier: Modifier = Modifier, 285 | titleString: String, 286 | titleIcon: ImageVector, 287 | opened: MutableState, 288 | dismissButton: @Composable (() -> Unit)? = null, 289 | content: @Composable (() -> Unit)? = null, 290 | ) { 291 | AlertDialog( 292 | modifier = modifier 293 | .fillMaxWidth(), 294 | //.wrapContentSize(), 295 | title = { 296 | Row { 297 | Icon( 298 | titleIcon, 299 | contentDescription = null, 300 | modifier = Modifier.size(ButtonDefaults.IconSize) 301 | ) 302 | Spacer(Modifier.size(ButtonDefaults.IconSpacing)) 303 | Text(text = titleString) 304 | } 305 | }, 306 | shape = RoundedCornerShape(20.dp), 307 | onDismissRequest = { 308 | opened.value = false 309 | }, 310 | text = content, 311 | confirmButton = { 312 | TextButton( 313 | onClick = { opened.value = false }) { 314 | Text(stringResource(id = android.R.string.ok)) 315 | } 316 | }, 317 | dismissButton = dismissButton, 318 | ) 319 | } 320 | 321 | /** 322 | * Preview ui elements in design preview of android studio 323 | * TODO: add more UI elements 324 | */ 325 | 326 | @Preview 327 | @Composable 328 | fun PassText() { 329 | Box { 330 | CustomCardBool("True", true) 331 | } 332 | } 333 | 334 | @Preview 335 | @Composable 336 | fun FailText() { 337 | Box { 338 | CustomCardBool("False", false) 339 | } 340 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/CustomCards.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.outlined.Help 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import com.henrikherzig.playintegritychecker.R 14 | 15 | /** 16 | * settings ui for the request 17 | */ 18 | @Composable 19 | fun RequestSettings( 20 | selectedIndexCheck: String, 21 | itemsCheck: List>, 22 | changedCheck: (String) -> Unit, 23 | selectedIndexNonce: String, 24 | itemsNonce: List>, 25 | changedNonce: (String) -> Unit, 26 | ) { 27 | Column( 28 | modifier = Modifier 29 | .padding(12.dp) 30 | .fillMaxWidth(), 31 | ) { 32 | val openedNonce = remember { mutableStateOf(false) } 33 | val openedCheck = remember { mutableStateOf(false) } 34 | CustomCardTitle(text = stringResource(id = R.string.requestSettings_title)) 35 | Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { 36 | CustomCardTitle3(text = stringResource(id = R.string.requestSettings_nonceCreation)) 37 | CustomHelpButton(onClick = {openedNonce.value = true}) 38 | if (openedNonce.value) { 39 | CustomTextAlertDialog( 40 | stringResource(id = R.string.requestSettings_nonceCreation), 41 | stringResource(id = R.string.requestSettings_nonceCreation_helpText), 42 | opened = openedNonce, 43 | ) 44 | } 45 | } 46 | Spacer(modifier = Modifier.height(8.dp)) 47 | ToggleGroup(selectedIndexCheck, itemsCheck, changedCheck, 35.dp) 48 | Spacer(modifier = Modifier.height(12.dp)) 49 | Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { 50 | CustomCardTitle3(text = stringResource(id = R.string.requestSettings_checkVerdict)) 51 | CustomHelpButton(onClick = {openedCheck.value = true}) 52 | if (openedCheck.value) { 53 | CustomTextAlertDialog( 54 | stringResource(id = R.string.requestSettings_checkVerdict), 55 | stringResource(id = R.string.requestSettings_checkVerdict_helpText), 56 | opened = openedCheck, 57 | ) 58 | } 59 | } 60 | Spacer(modifier = Modifier.height(8.dp)) 61 | ToggleGroup(selectedIndexNonce, itemsNonce, changedNonce, 35.dp) 62 | //CustomCard 63 | } 64 | } 65 | 66 | /** 67 | * shows device information in a card 68 | */ 69 | @Composable 70 | fun DeviceInfoContent(playServiceVersion: String?) { 71 | CustomCard { 72 | Column( 73 | modifier = Modifier 74 | .padding(12.dp) 75 | .fillMaxWidth(), 76 | ) { 77 | CustomCardTitle(text = stringResource(R.string.info_device)) 78 | 79 | CustomCardGroup( 80 | text1 = stringResource(R.string.info_model), 81 | text2 = "${Build.MODEL} (${Build.DEVICE})" 82 | ) 83 | CustomCardGroup( 84 | text1 = stringResource(R.string.info_androidVersion), 85 | text2 = "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" 86 | ) 87 | 88 | CustomCardGroup( 89 | text1 = stringResource(R.string.info_securityPatch), 90 | text2 = Build.VERSION.SECURITY_PATCH 91 | ) 92 | 93 | if (playServiceVersion != null) { 94 | CustomCardGroup( 95 | text1 = stringResource(R.string.info_playServicesVersion), 96 | text2 = playServiceVersion 97 | ) 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/CustomComponents.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.browser.customtabs.CustomTabsIntent 6 | import androidx.compose.animation.animateColorAsState 7 | import androidx.compose.animation.core.animateDpAsState 8 | import androidx.compose.animation.core.tween 9 | import androidx.compose.foundation.Canvas 10 | import androidx.compose.foundation.layout.* 11 | import androidx.compose.material.* 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Circle 14 | import androidx.compose.material.icons.outlined.Circle 15 | import androidx.compose.material.icons.outlined.Close 16 | import androidx.compose.material.icons.outlined.Help 17 | import androidx.compose.material.icons.outlined.OpenInNew 18 | import androidx.compose.runtime.* 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.geometry.CornerRadius 22 | import androidx.compose.ui.geometry.Offset 23 | import androidx.compose.ui.geometry.Size 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.unit.Dp 27 | import androidx.compose.ui.unit.dp 28 | import com.google.accompanist.pager.ExperimentalPagerApi 29 | import com.google.accompanist.pager.HorizontalPager 30 | import com.google.accompanist.pager.rememberPagerState 31 | import com.henrikherzig.playintegritychecker.R 32 | 33 | /** 34 | * opens a link in a preview window within the app 35 | */ 36 | fun openLink(url: String, context: Context) { 37 | CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url)) 38 | } 39 | 40 | /** 41 | * shows device information in a card 42 | */ 43 | @OptIn(ExperimentalMaterialApi::class) 44 | @Composable 45 | fun CustomButtonRow(context: Context, linkIdPairs: List>) { 46 | Row( 47 | modifier = Modifier 48 | .fillMaxWidth(), 49 | horizontalArrangement = Arrangement.spacedBy(16.dp) 50 | ) { 51 | linkIdPairs.forEachIndexed { _, item -> 52 | CompositionLocalProvider( 53 | LocalMinimumTouchTargetEnforcement provides false, 54 | ) { 55 | Box( 56 | modifier = Modifier 57 | .fillMaxWidth() 58 | .weight(1f) 59 | ) { 60 | CustomButton( 61 | { openLink(item.first, context) }, 62 | Icons.Outlined.OpenInNew, 63 | item.second 64 | ) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * shows help card button 73 | */ 74 | @Composable 75 | fun CustomHelpButton(onClick: () -> Unit) { 76 | IconButton( 77 | onClick = onClick, 78 | modifier = Modifier.size(ButtonDefaults.IconSize) 79 | ) { 80 | Icon( 81 | Icons.Outlined.Help, 82 | contentDescription = null, 83 | modifier = Modifier.size(ButtonDefaults.IconSize), 84 | tint = MaterialTheme.colors.primary 85 | ) 86 | } 87 | } 88 | 89 | 90 | /** 91 | * shows close card button 92 | */ 93 | @Composable 94 | fun CustomCloseButton(onClick: () -> Unit) { 95 | IconButton( 96 | onClick = onClick, 97 | modifier = Modifier.size(ButtonDefaults.IconSize) 98 | ) { 99 | Icon( 100 | Icons.Outlined.Close, 101 | contentDescription = null, 102 | modifier = Modifier.size(ButtonDefaults.IconSize), 103 | tint = MaterialTheme.colors.primary 104 | ) 105 | } 106 | } 107 | 108 | /** 109 | * shows a Threema inspired three dot element indicating the verdict strength 110 | * [state] = [1,2,3] where 1 is low, 2 is medium and 3 is strong 111 | */ 112 | @Composable 113 | fun CustomThreeStateIcons(state: Int, text: String) { 114 | val color = if (text == "MEETS_VIRTUAL_INTEGRITY") Color.Blue else when (state) { 115 | 0 -> Color(0xFFE53935) 116 | 1 -> Color(0xFFF57C00) 117 | 2 -> Color(0xFFFFC107) 118 | 3 -> Color(0xFF7CB342) 119 | else -> Color.Gray 120 | } 121 | Row(verticalAlignment = Alignment.CenterVertically) { 122 | Icon( 123 | if (state > 0) { 124 | Icons.Filled.Circle 125 | } else { 126 | Icons.Outlined.Circle 127 | }, 128 | contentDescription = null, 129 | modifier = Modifier.size(ButtonDefaults.IconSize), 130 | tint = color 131 | ) 132 | Icon( 133 | if (state > 1) { 134 | Icons.Filled.Circle 135 | } else { 136 | Icons.Outlined.Circle 137 | }, 138 | contentDescription = null, 139 | modifier = Modifier.size(ButtonDefaults.IconSize), 140 | tint = color 141 | ) 142 | Icon( 143 | if (state > 2) { 144 | Icons.Filled.Circle 145 | } else { 146 | Icons.Outlined.Circle 147 | }, 148 | contentDescription = null, 149 | modifier = Modifier.size(ButtonDefaults.IconSize), 150 | tint = color 151 | ) 152 | Spacer(modifier = Modifier.width(5.dp)) 153 | Text( 154 | text = text, 155 | color = color 156 | ) 157 | } 158 | } 159 | 160 | 161 | data class HorizontalPagerContent( 162 | val threeState: Pair, 163 | val text: String, 164 | ) 165 | 166 | data class HorizontalPagerContentText( 167 | val title: String, 168 | val description: String 169 | ) 170 | 171 | @OptIn(ExperimentalPagerApi::class) 172 | @Composable 173 | fun CustomSlideStackDeviceRecognitionVerdict(initialPage: Int) { 174 | 175 | @Composable 176 | fun createItems() = listOf( 177 | HorizontalPagerContent( 178 | threeState = Pair(0,"NO_INTEGRITY"), 179 | text = stringResource(id = R.string.deviceRecognition_help_NO_INTEGRITY) 180 | ), 181 | HorizontalPagerContent( 182 | threeState = Pair(1,"MEETS_BASIC_INTEGRITY"), 183 | text = stringResource(id = R.string.deviceRecognition_help_MEETS_BASIC_INTEGRITY) 184 | ), 185 | HorizontalPagerContent( 186 | threeState = Pair(2,"MEETS_DEVICE_INTEGRITY"), 187 | text = stringResource(id = R.string.deviceRecognition_help_MEETS_DEVICE_INTEGRITY) 188 | ), 189 | HorizontalPagerContent( 190 | threeState = Pair(3,"MEETS_STRONG_INTEGRITY"), 191 | text = stringResource(id = R.string.deviceRecognition_help_MEETS_STRONG_INTEGRITY) 192 | ), 193 | HorizontalPagerContent( 194 | threeState = Pair(0,"MEETS_VIRTUAL_INTEGRITY"), 195 | text = stringResource(id = R.string.deviceRecognition_help_MEETS_VIRTUAL_INTEGRITY) 196 | ) 197 | ) 198 | 199 | val items = createItems() 200 | 201 | val pagerState = rememberPagerState(initialPage) 202 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 203 | HorizontalPager(count = items.size, state = pagerState) { page -> 204 | Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier 205 | .fillMaxHeight() 206 | .padding(top = 60.dp, bottom = 30.dp)) { 207 | val item = items[currentPage] 208 | CustomThreeStateIcons(item.threeState.first, item.threeState.second) 209 | Spacer(modifier = Modifier.height(20.dp)) 210 | Text(text = item.text) 211 | //Spacer(modifier = Modifier.weight(1f)) 212 | } 213 | } 214 | PageIndicator( 215 | numberOfPages = items.size, 216 | selectedPage = pagerState.currentPage, 217 | defaultRadius = 20.dp, 218 | selectedLength = 40.dp, 219 | space = 10.dp, 220 | animationDurationInMillis = 500, 221 | ) 222 | } 223 | } 224 | 225 | @OptIn(ExperimentalPagerApi::class) 226 | @Composable 227 | fun CustomSlideStackRecentDeviceActivity(initialPage: Int) { 228 | 229 | @Composable 230 | fun createItems() = listOf( 231 | HorizontalPagerContentText( 232 | title = "LEVEL_1", 233 | description = stringResource(id = R.string.recentDeviceActivity_help_LEVEL_1) 234 | ), 235 | HorizontalPagerContentText( 236 | title = "LEVEL_2", 237 | description = stringResource(id = R.string.recentDeviceActivity_help_LEVEL_2) 238 | ), 239 | HorizontalPagerContentText( 240 | title = "LEVEL_3", 241 | description = stringResource(id = R.string.recentDeviceActivity_help_LEVEL_3) 242 | ), 243 | HorizontalPagerContentText( 244 | title = "LEVEL_4", 245 | description = stringResource(id = R.string.recentDeviceActivity_help_LEVEL_4) 246 | ), 247 | HorizontalPagerContentText( 248 | title = "UNEVALUATED", 249 | description = stringResource(id = R.string.recentDeviceActivity_help_UNEVALUATED) 250 | ), 251 | ) 252 | 253 | val items = createItems() 254 | 255 | val pagerState = rememberPagerState(initialPage) 256 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 257 | HorizontalPager(count = items.size, state = pagerState) { page -> 258 | Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier 259 | .fillMaxHeight() 260 | .padding(top = 60.dp, bottom = 30.dp)) { 261 | val item = items[currentPage] 262 | CustomCardTitle2(item.title) 263 | Spacer(modifier = Modifier.height(20.dp)) 264 | Text(text = item.description) 265 | } 266 | } 267 | PageIndicator( 268 | numberOfPages = items.size, 269 | selectedPage = pagerState.currentPage, 270 | defaultRadius = 20.dp, 271 | selectedLength = 40.dp, 272 | space = 10.dp, 273 | animationDurationInMillis = 500, 274 | ) 275 | } 276 | } 277 | 278 | 279 | @Composable 280 | fun PageIndicator( 281 | numberOfPages: Int, 282 | modifier: Modifier = Modifier, 283 | selectedPage: Int = 0, 284 | selectedColor: Color = Color.Blue, 285 | defaultColor: Color = Color.LightGray, 286 | defaultRadius: Dp = 20.dp, 287 | selectedLength: Dp = 60.dp, 288 | space: Dp = 30.dp, 289 | animationDurationInMillis: Int = 300, 290 | ) { 291 | Row( 292 | verticalAlignment = Alignment.CenterVertically, 293 | horizontalArrangement = Arrangement.spacedBy(space), 294 | modifier = modifier, 295 | ) { 296 | for (i in 0 until numberOfPages) { 297 | val isSelected = i == selectedPage 298 | PageIndicatorView( 299 | isSelected = isSelected, 300 | selectedColor = selectedColor, 301 | defaultColor = defaultColor, 302 | defaultRadius = defaultRadius, 303 | selectedLength = selectedLength, 304 | animationDurationInMillis = animationDurationInMillis, 305 | ) 306 | } 307 | } 308 | } 309 | 310 | @Composable 311 | fun PageIndicatorView( 312 | isSelected: Boolean, 313 | selectedColor: Color, 314 | defaultColor: Color, 315 | defaultRadius: Dp, 316 | selectedLength: Dp, 317 | animationDurationInMillis: Int, 318 | modifier: Modifier = Modifier, 319 | ) { 320 | 321 | val color: Color by animateColorAsState( 322 | targetValue = if (isSelected) { 323 | selectedColor 324 | } else { 325 | defaultColor 326 | }, 327 | animationSpec = tween( 328 | durationMillis = animationDurationInMillis, 329 | ) 330 | ) 331 | val width: Dp by animateDpAsState( 332 | targetValue = if (isSelected) { 333 | selectedLength 334 | } else { 335 | defaultRadius 336 | }, 337 | animationSpec = tween( 338 | durationMillis = animationDurationInMillis, 339 | ) 340 | ) 341 | 342 | Canvas( 343 | modifier = modifier 344 | .size( 345 | width = width, 346 | height = defaultRadius, 347 | ), 348 | ) { 349 | drawRoundRect( 350 | color = color, 351 | topLeft = Offset.Zero, 352 | size = Size( 353 | width = width.toPx(), 354 | height = defaultRadius.toPx(), 355 | ), 356 | cornerRadius = CornerRadius( 357 | x = defaultRadius.toPx(), 358 | y = defaultRadius.toPx(), 359 | ), 360 | ) 361 | } 362 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/CustomTextField.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import androidx.compose.foundation.interaction.MutableInteractionSource 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.foundation.text.BasicTextField 7 | import androidx.compose.foundation.text.KeyboardActions 8 | import androidx.compose.foundation.text.KeyboardOptions 9 | import androidx.compose.material.ExperimentalMaterialApi 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.TextFieldDefaults 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.platform.LocalFocusManager 16 | import androidx.compose.ui.text.TextStyle 17 | import androidx.compose.ui.text.input.ImeAction 18 | import androidx.compose.ui.text.input.VisualTransformation 19 | import androidx.compose.ui.unit.dp 20 | 21 | /** 22 | * custom implementation of textField in order to get rid of unnecessary padding 23 | */ 24 | @OptIn(ExperimentalMaterialApi::class) 25 | @Composable 26 | fun CustomTextField( 27 | value: String, 28 | modifier: Modifier = Modifier, 29 | hideKeyboard: Boolean = false, 30 | onFocusClear: () -> Unit = {}, 31 | onSearch: (String) -> Unit = {}, 32 | onValueChange: (String) -> Unit, 33 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 34 | placeholder: @Composable (() -> Unit)? = null, 35 | ) { 36 | val focusManager = LocalFocusManager.current 37 | BasicTextField( 38 | value = value, 39 | onValueChange = onValueChange, 40 | modifier = modifier, 41 | interactionSource = interactionSource, 42 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 43 | keyboardActions = KeyboardActions(onSearch = { 44 | focusManager.clearFocus() 45 | onSearch(value) 46 | }), 47 | enabled = true, 48 | singleLine = true, 49 | textStyle = TextStyle(color = MaterialTheme.colors.primary) 50 | ) { innerTextField -> 51 | TextFieldDefaults.OutlinedTextFieldDecorationBox( 52 | value = value, 53 | innerTextField = innerTextField, 54 | singleLine = true, 55 | enabled = true, 56 | interactionSource = interactionSource, 57 | contentPadding = PaddingValues(horizontal = 12.dp), 58 | visualTransformation = VisualTransformation.None, 59 | placeholder = placeholder, 60 | border = { 61 | TextFieldDefaults.BorderBox( 62 | shape = RoundedCornerShape(8.dp), 63 | enabled = true, 64 | isError = false, 65 | colors = TextFieldDefaults.outlinedTextFieldColors(), 66 | interactionSource = interactionSource, 67 | ) 68 | } 69 | ) 70 | } 71 | if (hideKeyboard) { 72 | focusManager.clearFocus() 73 | // Call onFocusClear to reset hideKeyboard state to false 74 | onFocusClear() 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/CustomViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.booleanPreferencesKey 7 | import androidx.datastore.preferences.core.edit 8 | import androidx.datastore.preferences.core.stringPreferencesKey 9 | import androidx.lifecycle.MutableLiveData 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.viewModelScope 12 | import kotlinx.coroutines.flow.collectLatest 13 | import kotlinx.coroutines.launch 14 | 15 | class CustomViewModel( 16 | private val dataStore: DataStore 17 | ) : ViewModel() { 18 | private val forceDarkModeKey = booleanPreferencesKey("theme") 19 | private val serverURL = stringPreferencesKey("server_url") 20 | 21 | val stateTheme = MutableLiveData(null) 22 | val stateURL: MutableLiveData = MutableLiveData("") 23 | 24 | fun requestTheme() { 25 | viewModelScope.launch { 26 | dataStore.data.collectLatest { 27 | stateTheme.value = it[forceDarkModeKey] 28 | } 29 | } 30 | } 31 | 32 | fun requestURL() { 33 | viewModelScope.launch { 34 | dataStore.data.collectLatest { 35 | stateURL.value = it[serverURL] 36 | } 37 | } 38 | } 39 | 40 | fun switchToUseSystemSettings(isSystemSettings: Boolean) { 41 | viewModelScope.launch { 42 | if (isSystemSettings) { 43 | dataStore.edit { 44 | it.remove(forceDarkModeKey) 45 | } 46 | } 47 | } 48 | } 49 | 50 | fun switchToUseDarkMode(isDarkTheme: Boolean) { 51 | viewModelScope.launch { 52 | dataStore.edit { 53 | it[forceDarkModeKey] = isDarkTheme 54 | } 55 | } 56 | } 57 | 58 | fun setURL(url: String) { 59 | if (url == "") { 60 | removeURL() 61 | return 62 | } 63 | viewModelScope.launch { 64 | dataStore.edit { 65 | it[serverURL] = url 66 | } 67 | } 68 | } 69 | 70 | private fun removeURL() { 71 | viewModelScope.launch { 72 | dataStore.edit { 73 | it.remove(serverURL) 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/ResponseType.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui 2 | 3 | import com.henrikherzig.playintegritychecker.attestation.PlayIntegrityStatement 4 | import com.henrikherzig.playintegritychecker.attestation.safetynet.SafetyNetStatement 5 | 6 | sealed class ResponseType { 7 | class SuccessSafetyNet(val value: SafetyNetStatement) : ResponseType() 8 | class SuccessPlayIntegrity(val value: PlayIntegrityStatement) : ResponseType() 9 | //class SuccessGeneric(val value: R) : ResponseType() 10 | class SuccessSimple(val value: String) : ResponseType() 11 | class Failure(val error: Throwable) : ResponseType() 12 | object Loading : ResponseType() 13 | object None : ResponseType() 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/about/AboutPage.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.about 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.foundation.verticalScroll 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.outlined.Code 9 | import androidx.compose.material.icons.outlined.OpenInNew 10 | import androidx.compose.material.icons.outlined.ReadMore 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.unit.dp 19 | import androidx.navigation.NavHostController 20 | import com.henrikherzig.playintegritychecker.R 21 | import com.henrikherzig.playintegritychecker.ui.* 22 | 23 | @Composable 24 | fun AboutPage(navController: NavHostController) { 25 | val context = LocalContext.current 26 | Box( 27 | modifier = Modifier 28 | .fillMaxWidth() 29 | ) { 30 | Column( 31 | modifier = Modifier 32 | .verticalScroll(rememberScrollState()) 33 | //.widthIn(max = 600.dp) 34 | .padding(all = 12.dp) 35 | ) { 36 | CustomCardTitle("About this App") 37 | CustomCard { 38 | Column( 39 | modifier = Modifier 40 | .padding(12.dp) 41 | .fillMaxWidth(), 42 | ) { 43 | Row(verticalAlignment = Alignment.CenterVertically/*, modifier = Modifier.padding(12.dp)*/){ 44 | Text(stringResource(id = R.string.about_this_app)) 45 | } 46 | } 47 | } 48 | Spacer(Modifier.size(12.dp)) 49 | 50 | CustomCardTitle("API Info") 51 | Row( 52 | modifier = Modifier 53 | .fillMaxWidth(), 54 | //.padding(16.dp), 55 | horizontalArrangement = Arrangement.spacedBy(16.dp) 56 | ) { 57 | Box( 58 | modifier = Modifier 59 | .fillMaxWidth() 60 | .weight(1f) 61 | ) { 62 | /* Source Code Link */ 63 | val link = stringResource(id = R.string.about_api_playIntegrityLink) 64 | CustomButton( 65 | { openLink(link, context) }, 66 | Icons.Outlined.OpenInNew, 67 | stringResource(id = R.string.about_api_playIntegrityButton) 68 | ) 69 | } 70 | Box( 71 | modifier = Modifier 72 | .fillMaxWidth() 73 | .weight(1f) 74 | ) { 75 | /* Play Integrity API Link */ 76 | val link = stringResource(id = R.string.about_api_safetyNetLink) 77 | CustomButton( 78 | { openLink(link, context) }, 79 | Icons.Outlined.OpenInNew, 80 | stringResource(id = R.string.about_api_safetyNetButton) 81 | ) 82 | } 83 | } 84 | Spacer(Modifier.size(12.dp)) 85 | CustomCardTitle("Source code") 86 | Row( 87 | modifier = Modifier 88 | .fillMaxWidth(), 89 | horizontalArrangement = Arrangement.spacedBy(16.dp) 90 | ) { 91 | /* workaround: .weight is not accessible in button directly and also not if box 92 | is extracted to other method */ 93 | Box( 94 | modifier = Modifier 95 | .fillMaxWidth() 96 | .weight(1f) 97 | ) { 98 | /* Source Code Link */ 99 | val link = stringResource(id = R.string.about_sourceCode_appLink) 100 | CustomButton( 101 | { openLink(link, context) }, 102 | Icons.Outlined.Code, 103 | stringResource(id = R.string.about_sourceCode_appButton) 104 | ) 105 | } 106 | Box( 107 | modifier = Modifier 108 | .fillMaxWidth() 109 | .weight(1f) 110 | ) { 111 | /* Play Integrity API Link */ 112 | val link = stringResource(id = R.string.about_sourceCode_serverLink) 113 | CustomButton( 114 | { openLink(link, context) }, 115 | Icons.Outlined.Code, 116 | stringResource(id = R.string.about_sourceCode_serverButton) 117 | ) 118 | } 119 | } 120 | Spacer(Modifier.size(12.dp)) 121 | CustomCardTitle(stringResource(id = R.string.about_licenseAndPrivacy)) 122 | Row( 123 | modifier = Modifier 124 | .fillMaxWidth(), 125 | //.padding(16.dp), 126 | horizontalArrangement = Arrangement.spacedBy(16.dp) 127 | ) { 128 | Box( 129 | modifier = Modifier 130 | .fillMaxWidth() 131 | .weight(1f) 132 | ) { 133 | /* License */ 134 | val openedRecognition = remember { mutableStateOf(false) } 135 | CustomButton( 136 | { openedRecognition.value = true }, 137 | Icons.Outlined.ReadMore, 138 | stringResource(id = R.string.about_licenseButton) 139 | ) 140 | if (openedRecognition.value) { 141 | openedRecognition.value=false 142 | navController.navigate("licence") { 143 | 144 | } 145 | } 146 | } 147 | Box( 148 | modifier = Modifier 149 | .fillMaxWidth() 150 | .weight(1f) 151 | ) { 152 | /* Privacy */ 153 | val link = stringResource(id = R.string.about_privacyLink) 154 | CustomButton( 155 | { openLink(link, context) }, 156 | Icons.Outlined.ReadMore, 157 | stringResource(id = R.string.about_privacyButton) // policy 158 | ) 159 | } 160 | } 161 | 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/navigationbar/BottomNavItem.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.navigationbar 2 | 3 | 4 | import androidx.annotation.StringRes 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.* 7 | import androidx.compose.material.icons.outlined.ContentCopy 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | import com.henrikherzig.playintegritychecker.R 11 | 12 | sealed class BottomNavItem(@StringRes var title:Int, var icon: ImageVector, var screen_route:String){ 13 | 14 | object PlayIntegrity : BottomNavItem(R.string.bottomBar_playIntegrity, Icons.Filled.VerifiedUser ,"play_integrity") 15 | object SafetyNet: BottomNavItem(R.string.bottomBar_safetyNet,Icons.Filled.VerifiedUser,"safety_net") 16 | object Settings: BottomNavItem(R.string.bottomBar_settings, Icons.Filled.Settings,"settings") 17 | object About: BottomNavItem(R.string.bottomBar_about,Icons.Filled.Info,"about") 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/navigationbar/NavigationBarWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.navigationbar 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.material.* 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.ArrowBack 10 | import androidx.compose.runtime.* 11 | import androidx.compose.runtime.livedata.observeAsState 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.platform.LocalContext 16 | import androidx.compose.ui.res.painterResource 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.text.style.TextAlign 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.unit.sp 21 | import androidx.navigation.NavDestination.Companion.hierarchy 22 | import androidx.navigation.NavGraph.Companion.findStartDestination 23 | import androidx.navigation.compose.NavHost 24 | import androidx.navigation.compose.composable 25 | import androidx.navigation.compose.currentBackStackEntryAsState 26 | import androidx.navigation.compose.rememberNavController 27 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 28 | import com.henrikherzig.playintegritychecker.R 29 | import com.henrikherzig.playintegritychecker.attestation.PlayIntegrityStatement 30 | import com.henrikherzig.playintegritychecker.attestation.safetynet.SafetyNetStatement 31 | import com.henrikherzig.playintegritychecker.dataStore 32 | import com.henrikherzig.playintegritychecker.ui.CustomCardTitle 33 | import com.henrikherzig.playintegritychecker.ui.about.AboutPage 34 | import com.henrikherzig.playintegritychecker.ui.playintegrity.PlayIntegrity 35 | import com.henrikherzig.playintegritychecker.ui.safetynet.SafetyNet 36 | import com.henrikherzig.playintegritychecker.ui.ResponseType 37 | import com.henrikherzig.playintegritychecker.ui.settings.Settings 38 | import com.henrikherzig.playintegritychecker.ui.CustomViewModel 39 | import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer 40 | 41 | @Composable 42 | fun BottomNavigationBar( 43 | safetyNetResult: State>, 44 | onSafetyNetRequest: (String, String, String?) -> Unit, 45 | playIntegrityResult: State>, 46 | onPlayIntegrityRequest: (String, String, String?) -> Unit, 47 | playServiceVersion: String? 48 | ) { 49 | val systemUiController = rememberSystemUiController() 50 | systemUiController.setSystemBarsColor(color = MaterialTheme.colors.primarySurface) 51 | systemUiController.setNavigationBarColor(color = MaterialTheme.colors.primarySurface) 52 | val navController = rememberNavController() 53 | val appPages = listOf( 54 | BottomNavItem.PlayIntegrity, 55 | BottomNavItem.SafetyNet, 56 | BottomNavItem.Settings, 57 | BottomNavItem.About, 58 | ) 59 | val navBackStackEntry by navController.currentBackStackEntryAsState() 60 | 61 | // TODO: very ugly better solution in the future 62 | // check 63 | var selectedIndexCheckPlayIntegrity by remember { mutableStateOf("local") } 64 | var selectedIndexCheckSafetyNet by remember { mutableStateOf("local") } 65 | 66 | val local: String = stringResource(id = R.string.requestSettings_local) 67 | val server: String = stringResource(id = R.string.requestSettings_server) 68 | val google: String = stringResource(id = R.string.requestSettings_google) 69 | val itemsCheck: List> = listOf(listOf("local", local), listOf("server", server)) 70 | val changedCheckPlayIntegrity: (idx: String) -> Unit = { 71 | selectedIndexCheckPlayIntegrity = it 72 | } 73 | val changedCheckSafetyNet: (idx: String) -> Unit = { 74 | selectedIndexCheckSafetyNet = it 75 | } 76 | var selectedIndexNoncePlayIntegrity by remember { mutableStateOf("local") } 77 | var selectedIndexNonceSafetyNet by remember { mutableStateOf("local") } 78 | 79 | val itemsNonce: List> = 80 | listOf(listOf("local", local), listOf("server", server), listOf("google", google)) 81 | val changedNoncePlayIntegrity: (idx: String) -> Unit = { 82 | selectedIndexNoncePlayIntegrity = it 83 | } 84 | val changedNonceSafetyNet: (idx: String) -> Unit = { 85 | selectedIndexNonceSafetyNet = it 86 | } 87 | 88 | // Better state handling: Use ViewModels 89 | val context = LocalContext.current 90 | 91 | val viewModel = remember { 92 | CustomViewModel(context.dataStore) 93 | } 94 | // url 95 | LaunchedEffect(viewModel) { 96 | viewModel.requestURL() 97 | } 98 | val urlValue = viewModel.stateURL.observeAsState().value 99 | 100 | val appBarHorizontalPadding = 0.dp 101 | val titleIconModifier = Modifier 102 | .fillMaxHeight() 103 | .width(72.dp - appBarHorizontalPadding) 104 | 105 | Scaffold( 106 | topBar = { 107 | TopAppBar( 108 | /* 109 | * app bar title of the app 110 | * first line: short app name variant in bigger font 111 | * second line: full app name variant in smaller font 112 | */ 113 | modifier= Modifier.fillMaxWidth(), 114 | title = { 115 | // TopAppBar Content 116 | Box(Modifier.height(40.dp)) { 117 | 118 | // Navigation Icon 119 | if (navBackStackEntry?.destination?.route == "licence") { 120 | Row(titleIconModifier, verticalAlignment = Alignment.CenterVertically) { 121 | IconButton( 122 | onClick = { navController.navigateUp() }, 123 | enabled = true, 124 | ) { 125 | Icon( 126 | imageVector = Icons.Filled.ArrowBack, 127 | contentDescription = "Back", 128 | ) 129 | } 130 | } 131 | } 132 | 133 | // Title 134 | Row(Modifier.fillMaxSize(), 135 | verticalAlignment = Alignment.CenterVertically) { 136 | Column( 137 | modifier = Modifier.fillMaxSize(), 138 | verticalArrangement = Arrangement.Center, 139 | ) { 140 | Text( 141 | text = stringResource(id = R.string.app_name_short), 142 | style = MaterialTheme.typography.subtitle2, 143 | fontSize = 18.sp, 144 | modifier = Modifier.align(Alignment.CenterHorizontally) 145 | ) 146 | Text( 147 | text = stringResource(id = R.string.app_name), 148 | style = MaterialTheme.typography.caption, 149 | modifier = Modifier.align(Alignment.CenterHorizontally) 150 | ) 151 | 152 | } 153 | 154 | } 155 | 156 | // Actions 157 | /* CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 158 | Row( 159 | Modifier.fillMaxHeight(), 160 | horizontalArrangement = Arrangement.End, 161 | verticalAlignment = Alignment.CenterVertically, 162 | ){} 163 | } */ 164 | } 165 | }, 166 | ) 167 | }, 168 | bottomBar = { 169 | BottomNavigation { 170 | // val navBackStackEntry by navController.currentBackStackEntryAsState() 171 | val currentDestination = navBackStackEntry?.destination 172 | appPages.forEach { screen -> 173 | BottomNavigationItem( 174 | icon = { Icon(screen.icon, contentDescription = null) }, 175 | label = { Text(stringResource(screen.title)) }, 176 | selected = currentDestination?.hierarchy?.any { it.route == screen.screen_route } == true, 177 | onClick = { 178 | navController.navigate(screen.screen_route) { 179 | // Pop up to the first page to avoid large stack 180 | popUpTo(navController.graph.findStartDestination().id) { 181 | saveState = true 182 | } 183 | // Avoid multiple versions of same page 184 | launchSingleTop = true 185 | // Restore state when selecting a previous page again 186 | // restoreState = true 187 | } 188 | } 189 | ) 190 | } 191 | } 192 | }, 193 | 194 | ) { innerPadding -> 195 | NavHost( 196 | navController, 197 | startDestination = BottomNavItem.PlayIntegrity.screen_route, 198 | Modifier.padding(innerPadding) 199 | ) { 200 | composable(BottomNavItem.PlayIntegrity.screen_route) { 201 | PlayIntegrity( 202 | playIntegrityResult, 203 | { 204 | onPlayIntegrityRequest( 205 | selectedIndexCheckPlayIntegrity, 206 | selectedIndexNoncePlayIntegrity, 207 | urlValue 208 | ) 209 | }, 210 | selectedIndexCheckPlayIntegrity, 211 | itemsCheck, 212 | changedCheckPlayIntegrity, 213 | selectedIndexNoncePlayIntegrity, 214 | itemsNonce, 215 | changedNoncePlayIntegrity 216 | ) 217 | } 218 | composable(BottomNavItem.SafetyNet.screen_route) { 219 | SafetyNet( 220 | safetyNetResult, 221 | { 222 | onSafetyNetRequest( 223 | selectedIndexCheckSafetyNet, 224 | selectedIndexNonceSafetyNet, 225 | urlValue 226 | ) 227 | }, 228 | selectedIndexCheckSafetyNet, 229 | itemsCheck, 230 | changedCheckSafetyNet, 231 | selectedIndexNonceSafetyNet, 232 | itemsNonce.subList(0, 2), 233 | changedNonceSafetyNet 234 | ) 235 | } 236 | composable(BottomNavItem.Settings.screen_route) { 237 | Settings(playServiceVersion) 238 | } 239 | composable(BottomNavItem.About.screen_route) { 240 | AboutPage(navController) 241 | } 242 | composable("licence") { 243 | // Licenses Page 244 | Column( 245 | modifier = Modifier 246 | .padding(all = 12.dp) 247 | ) { 248 | CustomCardTitle(stringResource(id = R.string.about_licenseButton)) 249 | LibrariesContainer() 250 | } 251 | } 252 | } 253 | } 254 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/playintegrity/PlayIntegrityPage.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.playintegrity 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.browser.customtabs.CustomTabsIntent 6 | import androidx.compose.animation.AnimatedVisibility 7 | import androidx.compose.animation.ExperimentalAnimationApi 8 | import androidx.compose.animation.animateContentSize 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material.* 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.outlined.GppGood 15 | import androidx.compose.runtime.* 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.unit.dp 20 | import androidx.core.content.ContextCompat 21 | import com.henrikherzig.playintegritychecker.R 22 | import com.henrikherzig.playintegritychecker.attestation.PlayIntegrityStatement 23 | import com.henrikherzig.playintegritychecker.ui.* 24 | 25 | /** 26 | * returns the main ui of the App 27 | * @param playIntegrityResult api request result. Can be called with a initial value [ResponseType.None] as long 28 | * as no api request has been made 29 | * @param onPlayIntegrityRequest function to trigger when the API request is being made 30 | */ 31 | @Composable 32 | fun PlayIntegrity( 33 | playIntegrityResult: State>, 34 | onPlayIntegrityRequest: () -> Unit, 35 | selectedIndexCheck: String, 36 | itemsCheck: List>, 37 | changedCheck: (String) -> Unit, 38 | selectedIndexNonce: String, 39 | itemsNonce: List>, 40 | changedNonce: (String) -> Unit, 41 | ) { 42 | val context = LocalContext.current 43 | 44 | /** 45 | * Opens the Link external within primary browser 46 | * function not used at the moment 47 | */ 48 | fun openLinkExternal(url: String) { 49 | // shorter alternative 50 | // val uriHandler = LocalUriHandler.current 51 | // uriHandler.openUri(url) 52 | 53 | runCatching { 54 | ContextCompat.startActivity( 55 | context, 56 | Intent(Intent.ACTION_VIEW).setData(Uri.parse(url)), 57 | null 58 | ) 59 | } 60 | } 61 | 62 | /** 63 | * opens a link in a preview window within the app 64 | */ 65 | fun openLink(url: String) { 66 | CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url)) 67 | } 68 | 69 | Box( 70 | modifier = Modifier 71 | .fillMaxWidth() 72 | ) { 73 | Column( 74 | modifier = Modifier 75 | .verticalScroll(rememberScrollState()) 76 | //.widthIn(max = 600.dp) 77 | .padding(all = 12.dp) 78 | ) { 79 | CustomCard() { 80 | RequestSettings( 81 | selectedIndexCheck, 82 | itemsCheck, 83 | changedCheck, 84 | selectedIndexNonce, 85 | itemsNonce, 86 | changedNonce 87 | ) 88 | } 89 | Spacer(modifier = Modifier.height(12.dp)) 90 | 91 | /* "Make Play Integrity Request" button */ 92 | CustomButton( 93 | onPlayIntegrityRequest, 94 | Icons.Outlined.GppGood, 95 | stringResource(id = R.string.playIntegrity_button) 96 | ) 97 | 98 | Spacer(modifier = Modifier.height(8.dp)) 99 | 100 | // determines weather it is necessary to show the card with the API request result or not 101 | val showPlayIntegrityContent = playIntegrityResult.value != ResponseType.None 102 | 103 | /* Result Card (only visible, when button was pressed) */ 104 | AnimatedVisibility(visible = showPlayIntegrityContent) { 105 | Box( 106 | // Animation of box getting bigger when API result is shown 107 | modifier = Modifier.animateContentSize() 108 | ) { 109 | CustomCard { 110 | ResultContent(playIntegrityResult) { 111 | //playIntegrityResult.value = ResponseType.None 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/safetynet/SafetyNetPage.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.safetynet 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.animateContentSize 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.outlined.GppGood 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.unit.dp 15 | import com.henrikherzig.playintegritychecker.attestation.safetynet.SafetyNetStatement 16 | import com.henrikherzig.playintegritychecker.R 17 | import com.henrikherzig.playintegritychecker.ui.CustomButton 18 | import com.henrikherzig.playintegritychecker.ui.CustomCard 19 | import com.henrikherzig.playintegritychecker.ui.RequestSettings 20 | import com.henrikherzig.playintegritychecker.ui.ResultContent 21 | import com.henrikherzig.playintegritychecker.ui.ResponseType 22 | 23 | /** 24 | * returns the main ui of the App 25 | * @param safetyNetResult api request result. Can be called with a initial value [ResponseType.None] as long 26 | * as no api request has been made 27 | * @param onSafetyNetRequest function to trigger when the API request is being made 28 | */ 29 | @Composable 30 | fun SafetyNet( 31 | safetyNetResult: State>, 32 | onSafetyNetRequest: () -> Unit, 33 | selectedIndexCheck: String, 34 | itemsCheck: List>, 35 | changedCheck: (String) -> Unit, 36 | selectedIndexNonce: String, 37 | itemsNonce: List>, 38 | changedNonce: (String) -> Unit, 39 | ) { 40 | Box( 41 | modifier = Modifier 42 | .fillMaxWidth() 43 | ) { 44 | /* Main column in which every other UI element for the main screen is in */ 45 | Column( 46 | modifier = Modifier 47 | .verticalScroll(rememberScrollState()) 48 | //.widthIn(max = 600.dp) 49 | .padding(all = 12.dp) 50 | ) { 51 | CustomCard { 52 | RequestSettings( 53 | selectedIndexCheck, 54 | itemsCheck, 55 | changedCheck, 56 | selectedIndexNonce, 57 | itemsNonce, 58 | changedNonce 59 | ) 60 | } 61 | Spacer(modifier = Modifier.height(12.dp)) 62 | /* "Make SafetyNet AttestationAPI Request" button */ 63 | CustomButton( 64 | onSafetyNetRequest, 65 | Icons.Outlined.GppGood, 66 | stringResource(id = R.string.safetyNet_attestation_button) 67 | ) 68 | 69 | Spacer(modifier = Modifier.height(8.dp)) 70 | 71 | // determines weather it is necessary to show the card with the API request result or not 72 | val showSafetyNetContent = safetyNetResult.value != ResponseType.None 73 | 74 | /* Result Card (only visible, when button was pressed) */ 75 | AnimatedVisibility(visible = showSafetyNetContent) { 76 | Box( 77 | // Animation of box getting bigger when API result is shown 78 | modifier = Modifier.animateContentSize() 79 | ) { 80 | CustomCard { 81 | ResultContent(safetyNetResult){ 82 | //playIntegrityResult.value = ResponseType.None 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/settings/SettingsPage.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.settings 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.* 10 | import androidx.compose.runtime.livedata.observeAsState 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import com.henrikherzig.playintegritychecker.R 17 | import com.henrikherzig.playintegritychecker.dataStore 18 | import com.henrikherzig.playintegritychecker.ui.* 19 | 20 | @Composable 21 | fun Settings(playServiceVersion: String?) { 22 | // get context, viewModel and stateTheme observer for detecting/setting ui changes 23 | val context = LocalContext.current 24 | val viewModel = remember { CustomViewModel(context.dataStore) } 25 | val themeValue = viewModel.stateTheme.observeAsState().value 26 | 27 | // variable to store state of theme selector 28 | val themeSelectorMode by remember(themeValue) { 29 | val mode = when (themeValue) { 30 | null -> "system" 31 | true -> "dark" 32 | false -> "light" 33 | } 34 | mutableStateOf(mode) 35 | } 36 | // theme selector options 37 | val system: String = stringResource(id = R.string.settings_theme_system) 38 | val light: String = stringResource(id = R.string.settings_theme_light) 39 | val dark: String = stringResource(id = R.string.settings_theme_dark) 40 | 41 | val themeSelectorOptions: List> = 42 | listOf(listOf("system", system), listOf("light", light), listOf("dark", dark)) 43 | val modeChanged: (String) -> Unit = { 44 | when (it) { 45 | "system" -> viewModel.switchToUseSystemSettings(true) 46 | "dark" -> viewModel.switchToUseDarkMode(true) 47 | "light" -> viewModel.switchToUseDarkMode(false) 48 | } 49 | } 50 | 51 | // update viewModel variables on change 52 | LaunchedEffect(viewModel) { 53 | viewModel.requestTheme() 54 | viewModel.requestURL() 55 | } 56 | 57 | // get url variable for textField 58 | val urlValue = viewModel.stateURL.observeAsState().value 59 | 60 | // variable for textField to set serverURL 61 | var text by remember(urlValue) { 62 | val text = when (urlValue) { 63 | null -> "" 64 | else -> urlValue 65 | } 66 | mutableStateOf(text) 67 | } 68 | 69 | // if clicked outside of the TextInputField for entering ServerURL the focus to it should be lost 70 | val interactionSource = MutableInteractionSource() 71 | var hideKeyboard by remember { mutableStateOf(false) } 72 | 73 | // define settings UI 74 | Box( 75 | modifier = Modifier 76 | .fillMaxWidth() 77 | .fillMaxHeight() 78 | .clickable( // no animation when clicked to lose focus of url textField 79 | interactionSource = interactionSource, 80 | indication = null 81 | ) { hideKeyboard = true } 82 | ) { 83 | Column( 84 | modifier = Modifier 85 | .verticalScroll(rememberScrollState()) 86 | .padding(all = 12.dp) 87 | ) { 88 | DeviceInfoContent(playServiceVersion) 89 | Spacer(Modifier.size(12.dp)) 90 | CustomCardTitle(stringResource(R.string.settings_title)) 91 | Row( 92 | verticalAlignment = Alignment.CenterVertically, 93 | modifier = Modifier 94 | // .fillMaxWidth() 95 | .padding(horizontal = 8.dp), 96 | //horizontalArrangement = Arrangement.spacedBy(16.dp) 97 | ) { 98 | /* workaround: .weight is not accessible in button directly and also not if box 99 | is extracted to other method, have to investigate this */ 100 | Box( 101 | modifier = Modifier 102 | .fillMaxWidth(fraction = 0.3f) 103 | //.weight(0.3f) 104 | //.absolutePadding(right = 12.dp) 105 | ) { 106 | Text(stringResource(R.string.settings_theme)) 107 | } 108 | Box( 109 | modifier = Modifier 110 | .fillMaxWidth() 111 | .weight(1f) 112 | //.absolutePadding(left = 12.dp) 113 | ) { 114 | ToggleGroup(themeSelectorMode, themeSelectorOptions, modeChanged, 35.dp) 115 | } 116 | } 117 | Spacer(modifier = Modifier.size(12.dp)) 118 | Row( 119 | verticalAlignment = Alignment.CenterVertically, 120 | modifier = Modifier 121 | // .fillMaxWidth() 122 | .padding(horizontal = 8.dp), 123 | //horizontalArrangement = Arrangement.spacedBy(16.dp) 124 | ) { 125 | /* workaround: .weight is not accessible in button directly and also not if box 126 | is extracted to other method, have to investigate this */ 127 | Box( 128 | modifier = Modifier 129 | .fillMaxWidth(fraction = 0.3f) 130 | //.weight(0.3f) 131 | //.absolutePadding(right = 12.dp) 132 | ) { 133 | Text(stringResource(R.string.settings_url)) 134 | } 135 | CustomTextField( 136 | value = text, 137 | onValueChange = { 138 | text = it 139 | }, 140 | hideKeyboard = hideKeyboard, 141 | onFocusClear = { 142 | viewModel.setURL(text) 143 | hideKeyboard = false 144 | }, 145 | onSearch = { 146 | viewModel.setURL(text) 147 | }, 148 | modifier = Modifier 149 | .fillMaxWidth() 150 | //.padding(8.dp) 151 | .height(35.dp), 152 | placeholder = { Text(stringResource(R.string.settings_url_hint)) } 153 | ) 154 | } 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.runtime.livedata.observeAsState 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.platform.LocalContext 12 | import com.henrikherzig.playintegritychecker.dataStore 13 | import com.henrikherzig.playintegritychecker.ui.CustomViewModel 14 | 15 | private val DarkColorPalette = darkColors( 16 | primary = Purple200, 17 | primaryVariant = Purple700, 18 | secondary = Teal200, 19 | //surface = Color(0xFF121212) 20 | ) 21 | 22 | private val LightColorPalette = lightColors( 23 | primary = Purple500, 24 | primaryVariant = Purple700, 25 | secondary = Teal200 26 | 27 | /* Other default colors to override 28 | background = Color.White, 29 | surface = Color.White, 30 | onPrimary = Color.White, 31 | onSecondary = Color.Black, 32 | onBackground = Color.Black, 33 | onSurface = Color.Black, 34 | */ 35 | ) 36 | 37 | @Composable 38 | fun PlayIntegrityCheckerTheme( 39 | //darkTheme: Boolean = isSystemInDarkTheme(), 40 | content: @Composable () -> Unit 41 | ) { 42 | val context = LocalContext.current 43 | val viewModel = remember { CustomViewModel(context.dataStore) } 44 | val state = viewModel.stateTheme.observeAsState() 45 | val value = state.value ?: isSystemInDarkTheme() 46 | LaunchedEffect(viewModel) { viewModel.requestTheme() } 47 | val colors = if (value) { 48 | DarkColorPalette 49 | } else { 50 | LightColorPalette 51 | } 52 | 53 | MaterialTheme( 54 | colors = colors, 55 | typography = Typography, 56 | shapes = Shapes, 57 | content = content 58 | ) 59 | } -------------------------------------------------------------------------------- /app/src/main/java/com/henrikherzig/playintegritychecker/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-de-rDE/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Simple Play Integrity Checker 4 | SPIC 5 | 6 | 7 | SafetyNet Attestation Ergebnis: 8 | Nonce 9 | Zeitstempel 10 | Auswertungstyp 11 | Einfache Integrität 12 | CTS Profil Übereinstimmung 13 | APK Paket Name 14 | APK Digest (Sha256) 15 | APK Zertifikat Digest (Sha256) 16 | 17 | Hardwaregestützt 18 | Einfach 19 | bestanden 20 | fehlgeschlagen 21 | unausgewertet 22 | 23 | SafetyNet Attestation Anfrage 24 | 25 | 26 | 27 | Play Integrity Ergebnis: 28 | 29 | Anfragendetails 30 | Nonce 31 | Anfragen Paket Name 32 | Zeitstempel 33 | 34 | App Integrität 35 | App Erkennungs Urteil 36 | APK Paket Name 37 | APK Zertifikat Digest (Sha256) 38 | Version Code 39 | 40 | Geräteintegrität 41 | Gerät Erkennungs Urteil 42 | Aktuelle Geräteaktivität 43 | 44 | Account Details 45 | App Lizensierungs Urteil 46 | 47 | Umgebungsdetails 48 | Play Protect Urteil 49 | 50 | Play Integrity Anfrage 51 | 52 | The device has signs of an attack (such as API hooking) or system compromise (such as being rooted), or it is not a physical device (such as an emulator that does not pass Google Play integrity checks). 53 | The device passes basic system integrity checks. It may not meet Android compatibility requirements and may not be approved to run Google Play services (eg. device is running unrecognized version of Android, may have an unlocked bootloader, or may not have been certified by the manufacturer). 54 | The Android device is powered by Google Play services. It passes system integrity checks and meets Android compatibility requirements. 55 | The Android device is powered by Google Play services and has a strong guarantee of system integrity such as a hardware-backed proof of boot integrity. It passes system integrity checks and meets Android compatibility requirements. 56 | The app is running on an Android emulator powered by Google Play services. The emulator passes system integrity checks and meets core Android compatibility requirements. 57 | 58 | Niedrigste Stufe: Die App hat in der letzten Stunde 10 oder weniger Integritätstoken auf diesem Gerät angefordert. 59 | Die App hat in der letzten Stunde zwischen 11 und 25 Integritätstoken auf diesem Gerät angefordert. 60 | Die App hat in der letzten Stunde zwischen 26 und 50 Integritätstoken auf diesem Gerät angefordert. 61 | Höchste Stufe: Die App hat in der letzten Stunde mehr als 50 Integritätstoken auf diesem Gerät angefordert. 62 | 63 | Die aktuelle Geräteaktivität wurde nicht ausgewertet. Hier sind einige der verschiedenen Gründe, warum dies passieren könnte:\n\n 64 | \u2022 Das Gerät ist nicht zuverlässig genug.\n 65 | \u2022 Die auf dem Gerät installierte Version Ihrer App ist Google Play nicht bekannt.\n 66 | \u2022 Das Gerät weist technische Probleme auf. 67 | 68 | 69 | 70 | Reines JSON 71 | JSON Exportieren 72 | lädt 73 | Reines JSON Ergebnis 74 | ein unbekannter Fehler ist aufgetreten 75 | 76 | Einstellungen zur Anfrage 77 | Nonce Erstellung 78 | Urteil überprüfen 79 | Lokal 80 | Server 81 | Google 82 | 83 | Determines if the nonce is created on the device locally or on the secure remote server. (Locally generated nonce are only for testing purposes and not recommended in production use). The usage of a nonce prevents replay attacks. 84 | Determines if the integrity verdict should be checked locally on the device or on a secure remote server. (Checking the verdict locally is only for testing purposes as it makes bypassing the check trivial). 85 | 86 | Urteilsstärke: 87 | 88 | 89 | Geräteinfo 90 | Modell 91 | Android Version 92 | Sicherheits-Patch 93 | Play Services Version 94 | Einstellungen 95 | Aussehen 96 | System 97 | Hell 98 | Dunkel 99 | Server URL 100 | eigene server url 101 | 102 | 103 | 104 | Diese App ist eine OpenSource Implementierung der Google Play Integrity API als auch der Safety Net Attestation API (API Informationen siehe unten). Die einzigartige Nonce Erstellung als auch die Überprüfung des Integritätsurteils beider APIs kann sowohl lokal auf dem Gerät als auch auf einem remote server, welcher die Serverimplementierung verwendet, geschehen. 105 | 106 | API Info 107 | Play Integrity 108 | SafetyNet 109 | 110 | Source Code 111 | Android App 112 | Check Server 113 | 114 | Lizenz und Privatsphäre 115 | Lizenzen 116 | Privatsphäre 117 | 118 | 119 | PlayIntegrity 120 | SafetyNet 121 | Einstellungen 122 | Über 123 | 124 | -------------------------------------------------------------------------------- /app/src/main/res/values-ja/strings.xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | Simple Play Integrity Checker 4 | SPIC 5 | 6 | SafetyNet 認証結果: 7 | ノンス 8 | タイムスタンプ 9 | 評価タイプ 10 | 基本的な完全性 11 | CTS プロファイルの一致 12 | APK パッケージ名 13 | APK ダイジェスト (SHA256) 14 | APK ダイジェスト証明書 (SHA256) 15 | ハードウェア証明済み 16 | Basic 17 | パス 18 | 失敗 19 | 評価なし 20 | SafetyNet 認証をリクエスト 21 | 22 | Play Integrity 認証結果: 23 | リクエストの詳細 24 | ノンス 25 | リクエストパッケージ名 26 | タイムスタンプ 27 | アプリの完全性 28 | アプリの完全性判定 29 | APK パッケージ名 30 | APK ダイジェスト証明書 (SHA256) 31 | バージョンコード 32 | デバイスの完全性 33 | デバイスの完全性判定 34 | アカウントの詳細 35 | アプリライセンスの完全性判定 36 | Play Integrity 認証をリクエスト 37 | デバイスの攻撃(API フッキングなど)やシステム侵害(root 化など)の兆候がある、または物理的なデバイスではない物(Google Play Integrity のチェックをパスしていないエミュレーターなど) 38 | デバイスが基本的なシステムの完全性のチェックにパスしています。Android の互換性要件を満たしていない可能性があり、Google Play サービスの実行が認定されていない可能性があります。(例: 非認識の環境で Android を実行、ブートローダーがアンロック、製造元によって非認定) 39 | Android デバイスは Google Play サービスを搭載しており、システムの完全性のチェックをパスと Android の互換性要件を満たしています。 40 | Android デバイスは Google Play サービスを搭載しており、ブートの完全性をハードウェアで証明するなど、システムの完全性を強力に保証しています。システムの完全性のチェックをパスと Android の互換性要件を満たしています。 41 | このアプリは、Google Play サービスを使用した Android エミュレーター上で動作しています。エミュレーターは、システムの完全性のチェックにパスをしており、Android のコアの互換性要件を満たしています。 42 | 43 | JSON を表示 44 | JSON をエクスポート 45 | 読み込み中 46 | 認証結果の JSON 47 | 不明なエラーが発生しました 48 | リクエストの設定 49 | ノンスの作成 50 | 完全性判定の確認 51 | ローカル 52 | サーバー 53 | Google 54 | ノンスをデバイスでローカル内に作成するか、セキュアなリモートサーバー上で作成するかを決定します。(ローカルで作成されたノンスはテスト用であり、本番での使用は推奨しません)ノンスを使用する事で、リプレイアタックを防ぐ事ができます。 55 | 完全性の判定をデバイスのローカル内でチェックをするか、完全なリモートサーバー上でチェックをするかを決定します。(ローカルでの判定はチェックを迂回する事が簡単になるため、テスト目的に限られます) 56 | 完全性の強度: 57 | 58 | デバイス情報 59 | モデル 60 | Android バージョン 61 | セキュリティパッチ 62 | Play サービスバージョン 63 | 設定 64 | テーマ 65 | システム 66 | ライト 67 | ダーク 68 | サーバー URL 69 | カスタムサーバー URL 70 | 71 | このアプリは、Google Play Integrity API と Safety Net Attestation API のオープンソース実装です。一意のノンス作成と両方の API から返される完全性のチェックは、デバイスのローカル上で実行かサーバー実装を使用してリモートサーバー上で実行する事ができます。 72 | API 情報 73 | Play Integrity 74 | https://developer.android.com/google/play/integrity 75 | SafetyNet 76 | https://developer.android.com/training/safetynet/attestation 77 | ソースコード 78 | Android アプリ 79 | https://github.com/herzhenr/spic-android 80 | サーバーを確認 81 | https://github.com/herzhenr/spic-server 82 | ライセンスとプライバシーポリシー 83 | ライセンス 84 | プライバシー 85 | https://github.com/herzhenr/spic-android/blob/main/privacyPolicy.md 86 | 87 | Play Integ. 88 | SafetyNet 89 | 設定 90 | アプリ情報 91 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt-rBR/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Simple Play Integrity Checker 4 | SPIC 5 | 6 | 7 | Resultado do SafetyNet: 8 | Nonce 9 | Data e hora 10 | Tipo de avaliação 11 | Integridade básica 12 | Correspondência de perfil CTS 13 | Nome do pacote APK 14 | Resumo do APK (SHA256) 15 | Resumo do certificado APK (SHA256) 16 | Suporte de hardware 17 | Básico 18 | passou 19 | falhou 20 | não avaliado 21 | Testar SafetyNet 22 | 23 | 24 | Resultado do Play Integrity: 25 | Solicitar detalhes 26 | Nonce 27 | Solicitar nome do pacote 28 | Data e hora 29 | Integridade do app 30 | Veredicto de reconhecimento do app 31 | Nome do pacote APK 32 | Resumo do certificado APK (SHA256) 33 | Código da versão 34 | Integridade do dispositivo 35 | Veredicto de reconhecimento do dispositivo 36 | Detalhes da conta 37 | Veredicto de licenciamento do app 38 | Testar Play Integrity 39 | O dispositivo apresenta sinais de ataque (como interceptação de API) ou comprometimento do sistema (como root) ou não é um dispositivo físico (como um emulador que não passa nas verificações de integridade do Google Play). 40 | O dispositivo passa nas verificações básicas de integridade do sistema. Ele pode não atender aos requisitos de compatibilidade do Android e pode não ser aprovado para executar o Google Play Services (por exemplo, o dispositivo está executando uma versão não reconhecida do Android, pode ter um bootloader desbloqueado ou pode não ter sido certificado pelo fabricante). 41 | O dispositivo Android é alimentado por Google Play Services. Ele passa nas verificações de integridade do sistema e atende aos requisitos de compatibilidade do Android. 42 | O dispositivo Android é alimentado pelo Google Play Services e tem uma forte garantia de integridade do sistema, como uma prova de integridade de inicialização apoiada por hardware. Ele passa nas verificações de integridade do sistema e atende aos requisitos de compatibilidade do Android. 43 | O app está sendo executado em um emulador Android desenvolvido pelo Google Play Services. O emulador passa nas verificações de integridade do sistema e atende aos principais requisitos de compatibilidade do Android. 44 | 45 | 46 | JSON bruto 47 | Exportar JSON 48 | carregando 49 | Resultado do JSON bruto 50 | ocorreu um erro desconhecido 51 | Configurações de solicitação 52 | Criação nonce 53 | Verificar veredicto 54 | local 55 | servidor 56 | google 57 | Determina se o nonce é criado localmente no dispositivo ou no servidor remoto seguro. (Os nonce gerados localmente são apenas para fins de teste e não são recomendados para uso em produção). O uso de um nonce evita ataques de repetição. 58 | Determina se o veredicto de integridade deve ser verificado localmente no dispositivo ou em um servidor remoto seguro. (Verificar o veredicto localmente é apenas para fins de teste, pois torna trivial ignorar a verificação). 59 | Força do veredicto: 60 | 61 | 62 | Informações do dispositivo 63 | Modelo 64 | Versão do Android 65 | Patch de segurança 66 | Versão do Google Play Services 67 | Configurações 68 | Tema 69 | sistema 70 | claro 71 | escuro 72 | URL do servidor 73 | URL do servidor personalizado 74 | 75 | 76 | Este app é uma implementação de código aberto da API Google Play Integrity, bem como da API Safety Net Attestation (informações da API, veja abaixo). A criação de nonce exclusivo, bem como as verificações do veredicto retornado por ambas as APIs, podem ser realizadas localmente no dispositivo ou em um servidor remoto usando a implementação do servidor. 77 | Informações da API 78 | Play Integrity 79 | SafetyNet 80 | Código fonte 81 | App Android 82 | Verificar servidor 83 | Licença e política de privacidade 84 | Licenças 85 | Privacidade 86 | 87 | 88 | Play Integ. 89 | SafetyNet 90 | Configurações 91 | Sobre 92 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3DDC84 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Simple Play Integrity Checker 4 | SPIC 5 | 6 | 7 | SafetyNet Attestation Result: 8 | Nonce 9 | Timestamp 10 | Evaluation Type 11 | Basic Integrity 12 | CTS Profile Match 13 | APK Package Name 14 | APK Digest (Sha256) 15 | APK Certificate Digest (Sha256) 16 | 17 | Hardware Backed 18 | Basic 19 | passed 20 | failed 21 | unevaluated 22 | 23 | Make SafetyNet Attestation Request 24 | 25 | 26 | 27 | Play Integrity Result: 28 | 29 | Request Details 30 | Nonce 31 | Request Package Name 32 | Timestamp 33 | 34 | App Integrity 35 | App recognition verdict 36 | APK Package Name 37 | APK Certificate Digest (Sha256) 38 | Version Code 39 | 40 | Device Integrity 41 | Device recognition verdict 42 | Recent device activity 43 | 44 | Account Details 45 | App licensing verdict 46 | 47 | Environment Details 48 | Play Protect Verdict 49 | 50 | Make Play Integrity Request 51 | 52 | The device has signs of an attack (such as API hooking) or system compromise (such as being rooted), or it is not a physical device (such as an emulator that does not pass Google Play integrity checks). 53 | The device passes basic system integrity checks. It may not meet Android compatibility requirements and may not be approved to run Google Play services (eg. device is running unrecognized version of Android, may have an unlocked bootloader, or may not have been certified by the manufacturer). 54 | The Android device is powered by Google Play services. It passes system integrity checks and meets Android compatibility requirements. 55 | The Android device is powered by Google Play services and has a strong guarantee of system integrity such as a hardware-backed proof of boot integrity. It passes system integrity checks and meets Android compatibility requirements. 56 | The app is running on an Android emulator powered by Google Play services. The emulator passes system integrity checks and meets core Android compatibility requirements. 57 | 58 | Lowest level: The app requested 10 or fewer integrity tokens on this device in the last hour. 59 | The app requested between 11 and 25 integrity tokens on this device in the last hour. 60 | The app requested between 26 and 50 integrity tokens on this device in the last hour. 61 | Highest level: The app requested more than 50 integrity tokens on this device in the last hour. 62 | 63 | Recent device activity was not evaluated. This could happen for several reasons, including the following:\n\n 64 | \u2022 The device is not trustworthy enough.\n 65 | \u2022 The version of your app installed on the device is unknown to Google Play.\n 66 | \u2022 Technical issues on the device. 67 | 68 | 69 | 70 | Raw JSON 71 | Export JSON 72 | loading 73 | Raw JSON result 74 | unknown error occurred 75 | 76 | Request Settings 77 | Nonce Creation 78 | Check Verdict 79 | local 80 | server 81 | google 82 | 83 | Determines if the nonce is created on the device locally or on the secure remote server. (Locally generated nonce are only for testing purposes and not recommended in production use). The usage of a nonce prevents replay attacks. 84 | Determines if the integrity verdict should be checked locally on the device or on a secure remote server. (Checking the verdict locally is only for testing purposes as it makes bypassing the check trivial). 85 | 86 | Verdict Strength: 87 | 88 | 89 | Device Info 90 | Model 91 | Android Version 92 | Security Patch 93 | Play Services Version 94 | Settings 95 | Theme 96 | system 97 | light 98 | dark 99 | Server URL 100 | custom server url 101 | 102 | 103 | 104 | This app is an open source implementation of the Google Play Integrity API as well as the Safety Net Attestation API (API info see below). The unique nonce creation as well as the checks of the verdict returned by both APIs can be performed locally on the device or on a remote server using the server implementation. 105 | 106 | API Info 107 | Play Integrity 108 | https://developer.android.com/google/play/integrity 109 | SafetyNet 110 | https://developer.android.com/training/safetynet/attestation 111 | 112 | Source Code 113 | Android App 114 | https://github.com/herzhenr/spic-android 115 | Check Server 116 | https://github.com/herzhenr/spic-server 117 | 118 | License and privacy policy 119 | Licenses 120 | Privacy 121 | https://github.com/herzhenr/spic-android/blob/main/privacyPolicy.md 122 | 123 | 124 | Play Integ. 125 | SafetyNet 126 | Settings 127 | About 128 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/test/java/com/henrikherzig/playintegritychecker/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.henrikherzig.playintegritychecker 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | compose_version = '1.3.0-beta01' 4 | accompanist_version = '0.24.12-rc' 5 | } 6 | repositories { 7 | google() // maven { url "https://maven.google.com" } for Gradle <= 3 8 | } 9 | dependencies { 10 | classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6' 11 | } 12 | }// Top-level build file where you can add configuration options common to all sub-projects/modules. 13 | plugins { 14 | id 'com.android.application' version '7.2.2' apply false 15 | id 'com.android.library' version '7.2.2' apply false 16 | id 'org.jetbrains.kotlin.android' version '1.7.10' apply false 17 | id 'com.mikepenz.aboutlibraries.plugin' version "10.5.2" apply false 18 | } 19 | 20 | task clean(type: Delete) { 21 | delete rootProject.buildDir 22 | } 23 | 24 | //allprojects { 25 | // tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 26 | // kotlinOptions { 27 | // freeCompilerArgs += [ 28 | // "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", 29 | // ] 30 | // } 31 | // } 32 | //} -------------------------------------------------------------------------------- /docs/aboutLight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/docs/aboutLight.png -------------------------------------------------------------------------------- /docs/googlePlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/docs/googlePlay.png -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/docs/icon.png -------------------------------------------------------------------------------- /docs/playIntegrityLight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/docs/playIntegrityLight.png -------------------------------------------------------------------------------- /docs/safetyNetJsonDark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/docs/safetyNetJsonDark.png -------------------------------------------------------------------------------- /docs/settingsLight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/docs/settingsLight.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | 25 | android.enableJetifier=true 26 | 27 | # Gradle properties for module app -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/herzhenr/spic-android/9d0a78479702b190a7e7c43db1a4d657f73ad378/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jul 22 15:27:39 CEST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /privacyPolicy.md: -------------------------------------------------------------------------------- 1 | **Privacy Policy** 2 | 3 | Henrik Herzig built the Simple Play Integrity Checker app as an Open Source app. This SERVICE is provided by Henrik Herzig at no cost and is intended for use as is. 4 | 5 | This page is used to inform visitors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my Service. 6 | 7 | If you choose to use my Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or share your information with anyone except as described in this Privacy Policy. 8 | 9 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which are accessible at Simple Play Integrity Checker unless otherwise defined in this Privacy Policy. 10 | 11 | **Information Collection and Use** 12 | 13 | For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information. The information that I request will be retained on your device and is not collected by me in any way. 14 | 15 | The app does use third-party services that may collect information used to identify you. 16 | 17 | Link to the privacy policy of third-party service providers used by the app 18 | 19 | * [Google Play Services](https://www.google.com/policies/privacy/) 20 | 21 | **Log Data** 22 | 23 | I want to inform you that whenever you use my Service, in a case of an error in the app I collect data and information (through third-party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing my Service, the time and date of your use of the Service, and other statistics. 24 | 25 | **Cookies** 26 | 27 | Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. 28 | 29 | This Service does not use these “cookies” explicitly. However, the app may use third-party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. 30 | 31 | **Service Providers** 32 | 33 | I may employ third-party companies and individuals due to the following reasons: 34 | 35 | * To facilitate our Service; 36 | * To provide the Service on our behalf; 37 | * To perform Service-related services; or 38 | * To assist us in analyzing how our Service is used. 39 | 40 | I want to inform users of this Service that these third parties have access to their Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the information for any other purpose. 41 | 42 | **Security** 43 | 44 | I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its absolute security. 45 | 46 | **Links to Other Sites** 47 | 48 | This Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the Privacy Policy of these websites. I have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. 49 | 50 | **Children’s Privacy** 51 | 52 | These Services do not address anyone under the age of 13. I do not knowingly collect personally identifiable information from children under 13 years of age. In the case I discover that a child under 13 has provided me with personal information, I immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact me so that I will be able to do the necessary actions. 53 | 54 | **Changes to This Privacy Policy** 55 | 56 | I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. 57 | 58 | This policy is effective as of 2023-01-11 59 | 60 | **Contact Us** 61 | 62 | If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at dev.homebrewed@google.com. 63 | 64 | This privacy policy page was created at [privacypolicytemplate.net](https://privacypolicytemplate.net) and modified/generated by [App Privacy Policy Generator](https://app-privacy-policy-generator.nisrulz.com/) 65 | 66 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "PlayIntegrityChecker" 16 | include ':app' 17 | --------------------------------------------------------------------------------