├── .editorconfig ├── .github └── workflows │ ├── build.yaml │ └── gradle-wrapper.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── build.gradle ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images └── sample.gif ├── renovate.json ├── settings.gradle ├── telescope-sample ├── build.gradle ├── debug.keystore └── src │ └── main │ ├── AndroidManifest.xml │ ├── application_icon-web.png │ ├── java │ └── com │ │ └── mattprecious │ │ └── telescope │ │ └── sample │ │ ├── SampleActivity.java │ │ └── ui │ │ ├── OtherTargetView.java │ │ ├── SampleAdditionalAttachmentEmailView.java │ │ ├── SampleEmailDeviceInfoView.java │ │ ├── SampleEmailView.java │ │ ├── SampleMapsView.java │ │ └── SampleToastView.java │ └── res │ ├── drawable │ ├── three_finger_press.xml │ ├── two_finger_press.xml │ └── two_finger_press_styled.xml │ ├── layout │ ├── additional_attachment_view.xml │ ├── attributions_view.xml │ ├── children_only_view.xml │ ├── default_view.xml │ ├── device_info_view.xml │ ├── maps_view.xml │ ├── other_target_view.xml │ ├── sample_activity.xml │ ├── styled_view.xml │ └── three_finger_view.xml │ ├── menu │ └── sample_menu.xml │ ├── mipmap-hdpi │ └── ic_launcher.png │ ├── mipmap-mdpi │ └── ic_launcher.png │ ├── mipmap-xhdpi │ └── ic_launcher.png │ ├── mipmap-xxhdpi │ └── ic_launcher.png │ ├── mipmap-xxxhdpi │ └── ic_launcher.png │ └── values │ ├── strings.xml │ └── styles.xml └── telescope ├── build.gradle ├── gradle.properties └── src └── main ├── AndroidManifest.xml ├── java └── com │ └── mattprecious │ └── telescope │ ├── BitmapProcessorListener.java │ ├── EmailDeviceInfoLens.java │ ├── EmailLens.java │ ├── FileProvider.java │ ├── Lens.java │ ├── Preconditions.java │ ├── RequestCaptureActivity.java │ ├── ScreenshotMode.java │ ├── TelescopeFileProvider.java │ ├── TelescopeLayout.java │ └── TelescopeProjectionService.java └── res ├── drawable └── telescope_service.xml ├── values ├── attrs.xml └── public.xml └── xml └── telescope_file_paths.xml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: {} 5 | workflow_dispatch: {} 6 | push: 7 | branches: 8 | - 'main' 9 | tags-ignore: 10 | - '**' 11 | 12 | env: 13 | GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dkotlin.incremental=false" 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: actions/setup-java@v4 23 | with: 24 | distribution: 'zulu' 25 | java-version: 17 26 | 27 | - uses: gradle/actions/setup-gradle@v4 28 | 29 | - run: ./gradlew build 30 | 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | name: telescope-sample-debug.apk 34 | path: telescope-sample/build/outputs/apk/debug/telescope-sample-debug.apk 35 | if-no-files-found: error 36 | -------------------------------------------------------------------------------- /.github/workflows/gradle-wrapper.yaml: -------------------------------------------------------------------------------- 1 | name: gradle-wrapper 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'gradlew' 7 | - 'gradlew.bat' 8 | - 'gradle/wrapper/' 9 | 10 | jobs: 11 | validate: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: gradle/actions/wrapper-validation@v4 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle 3 | local.properties 4 | build 5 | 6 | # IntelliJ IDEA 7 | .idea 8 | *.iml 9 | 10 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | Version 2.4.0 *(2024-11-11)* 5 | ---------------------------- 6 | 7 | * Use `PixelCopy` API for full-screen 'canvas' captures on API 26+. This supports hardware rendering 8 | so will no longer crash with hardware bitmaps (#142) and is also able to capture additional 9 | details like shadows from elevation. 10 | 11 | Version 2.3.0 *(2024-01-25)* 12 | ---------------------------- 13 | 14 | * Bump compile/target SDK to 34. 15 | * Add `FOREGROUND_SERVICE_MEDIA_PROJECTION` permission. 16 | 17 | Version 2.2.0 *(2019-10-29)* 18 | ---------------------------- 19 | 20 | * **Requires compileSdk 29** 21 | * Start a foreground service while capturing on Android 10. 22 | * Icon can be overridden in your application by adding a drawable named `telescope_service`. 23 | * Avoid activity leak in Android 10. 24 | * Migrate to AndroidX. 25 | 26 | Version 2.1.0 *(2016-06-01)* 27 | ---------------------------- 28 | 29 | * Moved anything that touches the disk to a background thread. 30 | * Note that if you override `EmailLens`' `getBody()` or `getAdditionalAttachments()`, you will now 31 | be called on a background thread. 32 | * `EmailLens` now shares screenshots using a `content://` URI instead of `file://` 33 | * Added `TelescopeFileProvider` for use in custom lenses that allows you to easily create a 34 | `content://` URI for sharing screenshots. See `EmailLens` for an example. 35 | 36 | Version 2.0.0 *(2016-02-10)* 37 | ---------------------------- 38 | 39 | * Screenshots will now be captured natively on API 21+. This method will capture the entire screen 40 | including status bar, navigation bar, and any surface views that didn't work using the old method 41 | (ex: `MapView`). The old method can be forced by setting the screenshot mode to 42 | `ScreenshotMode.CANVAS`. 43 | * Disabling of screenshots has been moved to `ScreenshotMode.NONE`. 44 | * `Lens` has been changed from an interface to an abstract class. 45 | * A new method has been added to `Lens` to optionally pre-process the screenshot before saving. 46 | * Layout attributes are now prefixed with `telescope_` to avoid collisions. 47 | 48 | Version 1.5.0 *(2015-09-22)* 49 | ---------------------------- 50 | 51 | * Removed the need for `WRITE_EXTERNAL_STORAGE` permission on KitKat+. 52 | * Screenshots are now stored in a private app directory on external storage. Ensure that you're 53 | calling `Telescope.cleanUp()` somewhere in your app lifecycle to remove these screenshots. 54 | * Check for `VIBRATE` permission before attempting to vibrate. 55 | 56 | Version 1.4.0 *(2015-02-06)* 57 | ---------------------------- 58 | 59 | * Removed override of `ViewParent#requestDisallowInterceptTouchEvent(boolean)` due to issues with 60 | `ListView` and other views. `TelescopeLayout` seems to obey `requestDisallowInterceptTouchEvent()` 61 | without any modifications. 62 | 63 | Version 1.3.0 *(2014-10-07)* 64 | ---------------------------- 65 | 66 | * New: Obey `ViewParent#requestDisallowInterceptTouchEvent(boolean)`. Have your multi-touch views 67 | call this method on their parent during touch events and Telescope will not intercept the events. 68 | * Removed `setInterceptTouchEvents(boolean)` and `attr/interceptTouchEvents` added in 1.2.0. Use 69 | `requestDisallowInterceptTouchEvent` instead. 70 | 71 | Version 1.2.0 *(2014-09-24)* 72 | ---------------------------- 73 | 74 | * New: Add ability to not intercept touch events. 75 | 76 | Version 1.1.0 *(2014-07-18)* 77 | ---------------------------- 78 | 79 | * New: Support for additional attachments in `EmailLens` and `EmailDeviceInfoLens`. 80 | * New: Convenience constructor for `EmailDeviceInfoLens` to automatically get app version. 81 | * Adjusted `EmailDeviceInfoLens` to add the separator and new lines below the body instead of above. 82 | * `EmailLens` and `EmailDeviceInfoLens` now prefer addresses as varargs instead of an array. 83 | 84 | Version 1.0.0 *(2014-05-01)* 85 | ---------------------------- 86 | 87 | Initial version. 88 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Telescope 2 | ====== 3 | 4 | A simple tool to allow easy bug report capturing within your app. 5 | 6 | ![](images/sample.gif) 7 | 8 | 9 | 10 | Usage 11 | ----- 12 | 13 | Place a `TelescopeLayout` at the root of your hierarchy. 14 | 15 | Add a callback to the view group with `setLens(Lens)`. Telescope provides two default 16 | implementations: 17 | 18 | * `EmailLens`: Compose an email with the provided addresses and subject (optional). 19 | * `EmailDeviceInfoLens`: Enhances `EmailLens` by pre-populating the email body with app and device 20 | info 21 | 22 | Screenshots will be stored on the external storage in your app's private directory. To have 23 | Telescope clean up the screenshots folder, call `TelescopeLayout.cleanUp(Context)`. Ideally, this 24 | would be called in the `onDestroy()` method of your `Activity` or `Fragment`. 25 | 26 | If you are using the Gradle-based build system, you can wrap this view group around your activity 27 | layouts only in the debug builds. 28 | 29 | See the [u2020 project][2] for a more advanced example. 30 | 31 | 32 | 33 | Permissions 34 | ----------- 35 | 36 | Pre-KitKat, `WRITE_EXTERNAL_STORAGE` is required for saving screenshots. Screenshots can be disabled 37 | using the configuration options below. 38 | 39 | 40 | 41 | Configuration 42 | ------------- 43 | 44 | The view group can be configured as follows: 45 | 46 | * Set the number of fingers to trigger with `app:telescope_pointerCount` / `setPointerCount(int)` 47 | * Set the progress color with `app:telescope_progressColor` / `setProgressColor(int)` 48 | * Change the screenshot method with `app:telescope_screenshotMode` / 49 | `setScreenshotMode(ScreenshotMode)` 50 | * Screenshot children only with `app:telescope_screenshotChildrenOnly` / 51 | `setScreenshotChildrenOnly(boolean)` 52 | * Set the screenshot target with`setScreenshotTarget(View)` 53 | * Disable vibration with `app:telescope_vibrate` / `setVibrate(boolean)` 54 | 55 | 56 | 57 | Download 58 | -------- 59 | 60 | Gradle: 61 | ```groovy 62 | compile 'com.mattprecious.telescope:telescope:2.4.0' 63 | ``` 64 | or Maven: 65 | ```xml 66 | 67 | com.mattprecious.telescope 68 | telescope 69 | 2.4.0 70 | apklib 71 | 72 | ``` 73 | 74 | 75 | License 76 | -------- 77 | 78 | Copyright 2014 Matthew Precious 79 | 80 | Licensed under the Apache License, Version 2.0 (the "License"); 81 | you may not use this file except in compliance with the License. 82 | You may obtain a copy of the License at 83 | 84 | http://www.apache.org/licenses/LICENSE-2.0 85 | 86 | Unless required by applicable law or agreed to in writing, software 87 | distributed under the License is distributed on an "AS IS" BASIS, 88 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 89 | See the License for the specific language governing permissions and 90 | limitations under the License. 91 | 92 | 93 | [1]: http://repository.sonatype.org/service/local/artifact/maven/redirect?r=central-proxy&g=com.mattprecious.telescope&a=telescope&v=LATEST 94 | [2]: https://github.com/jakewharton/u2020 95 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | } 6 | dependencies { 7 | classpath libs.androidGradlePlugin 8 | classpath libs.maven.publish.gradlePlugin 9 | } 10 | } 11 | 12 | allprojects { 13 | group = GROUP 14 | version = VERSION_NAME 15 | 16 | repositories { 17 | mavenCentral() 18 | google() 19 | } 20 | } 21 | 22 | subprojects { 23 | plugins.withId('com.android.base') { 24 | android { 25 | defaultConfig { 26 | minSdk 14 27 | compileSdk 34 28 | targetSdkVersion 34 29 | } 30 | 31 | compileOptions { 32 | sourceCompatibility JavaVersion.VERSION_11 33 | targetCompatibility JavaVersion.VERSION_11 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | VERSION_NAME=2.4.1-SNAPSHOT 2 | GROUP=com.mattprecious.telescope 3 | 4 | SONATYPE_HOST=DEFAULT 5 | SONATYPE_AUTOMATIC_RELEASE=true 6 | RELEASE_SIGNING_ENABLED=true 7 | 8 | POM_DESCRIPTION=A simple tool to allow easy bug report capturing within your app. 9 | POM_URL=https://github.com/mattprecious/telescope/ 10 | POM_SCM_URL=https://github.com/mattprecious/telescope/ 11 | POM_SCM_CONNECTION=scm:git@github.com/mattprecious/telescope.git 12 | POM_SCM_DEV_CONNECTION=scm:git@github.com:mattprecious/telescope.git 13 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 14 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 15 | POM_LICENCE_DIST=repo 16 | POM_DEVELOPER_ID=mattprecious 17 | POM_DEVELOPER_NAME=Matthew Precious 18 | 19 | android.useAndroidX=true 20 | android.enableJetifier=true 21 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [libraries] 2 | androidGradlePlugin = "com.android.tools.build:gradle:8.10.1" 3 | maven-publish-gradlePlugin = "com.vanniktech:gradle-maven-publish-plugin:0.32.0" 4 | 5 | androidx-annotations = "androidx.annotation:annotation:1.9.1" 6 | androidx-appCompat = "androidx.appcompat:appcompat:1.6.1" 7 | androidx-coordinatorLayout = "androidx.coordinatorlayout:coordinatorlayout:1.2.0" 8 | androidx-multidex = "androidx.multidex:multidex:2.0.1" 9 | androidx-viewPager = "androidx.viewpager:viewpager:1.0.0" 10 | 11 | material = "com.google.android.material:material:1.11.0" 12 | playServices-maps = "com.google.android.gms:play-services-maps:17.0.1" 13 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original 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 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /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 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /images/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/images/sample.gif -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'telescope-parent' 2 | 3 | enableFeaturePreview('TYPESAFE_PROJECT_ACCESSORS') 4 | 5 | include ':telescope' 6 | include ':telescope-sample' 7 | -------------------------------------------------------------------------------- /telescope-sample/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | dependencies { 4 | implementation projects.telescope 5 | 6 | implementation libs.androidx.coordinatorLayout 7 | implementation libs.androidx.viewPager 8 | implementation libs.androidx.appCompat 9 | implementation libs.androidx.multidex 10 | implementation libs.material 11 | implementation libs.playServices.maps 12 | } 13 | 14 | android { 15 | namespace 'com.mattprecious.telescope.sample' 16 | 17 | defaultConfig { 18 | applicationId 'com.mattprecious.telescope.sample' 19 | multiDexEnabled true 20 | } 21 | 22 | signingConfigs { 23 | debug { 24 | storeFile file("debug.keystore") 25 | } 26 | } 27 | 28 | buildTypes { 29 | debug { 30 | signingConfig signingConfigs.debug 31 | } 32 | } 33 | 34 | lintOptions { 35 | abortOnError false 36 | textOutput 'stdout' 37 | textReport true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /telescope-sample/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/debug.keystore -------------------------------------------------------------------------------- /telescope-sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 37 | 38 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /telescope-sample/src/main/application_icon-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/src/main/application_icon-web.png -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/SampleActivity.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample; 2 | 3 | import android.content.Context; 4 | import android.content.DialogInterface; 5 | import android.content.res.Configuration; 6 | import android.os.Bundle; 7 | import android.text.Html; 8 | import android.text.method.LinkMovementMethod; 9 | import android.view.LayoutInflater; 10 | import android.view.Menu; 11 | import android.view.MenuItem; 12 | import android.view.View; 13 | import android.view.ViewGroup; 14 | import android.widget.TextView; 15 | import androidx.annotation.LayoutRes; 16 | import androidx.annotation.StringRes; 17 | import androidx.appcompat.app.AlertDialog; 18 | import androidx.appcompat.app.AppCompatActivity; 19 | import androidx.viewpager.widget.PagerAdapter; 20 | import androidx.viewpager.widget.ViewPager; 21 | import com.google.android.material.tabs.TabLayout; 22 | import com.mattprecious.telescope.TelescopeLayout; 23 | import java.util.ArrayList; 24 | import java.util.List; 25 | 26 | public class SampleActivity extends AppCompatActivity { 27 | @Override protected void onCreate(Bundle savedInstanceState) { 28 | super.onCreate(savedInstanceState); 29 | setContentView(R.layout.sample_activity); 30 | 31 | setSupportActionBar(findViewById(R.id.toolbar)); 32 | 33 | Adapter adapter = new Adapter(this); 34 | adapter.addView(R.layout.default_view, R.string.tab_default); 35 | adapter.addView(R.layout.device_info_view, R.string.tab_device_info); 36 | adapter.addView(R.layout.styled_view, R.string.tab_styled); 37 | adapter.addView(R.layout.children_only_view, R.string.tab_children_only); 38 | adapter.addView(R.layout.three_finger_view, R.string.tab_three_finger); 39 | adapter.addView(R.layout.other_target_view, R.string.tab_other_target); 40 | adapter.addView(R.layout.additional_attachment_view, R.string.tab_additional_attachment); 41 | adapter.addView(R.layout.maps_view, R.string.tab_maps); 42 | 43 | ViewPager pagerView = findViewById(R.id.pager); 44 | pagerView.setAdapter(adapter); 45 | 46 | TabLayout tabsView = findViewById(R.id.tabs); 47 | tabsView.setupWithViewPager(pagerView); 48 | } 49 | 50 | @Override protected void onPostCreate(Bundle savedInstanceState) { 51 | super.onPostCreate(savedInstanceState); 52 | } 53 | 54 | @Override public void onConfigurationChanged(Configuration newConfig) { 55 | super.onConfigurationChanged(newConfig); 56 | } 57 | 58 | @Override protected void onDestroy() { 59 | super.onDestroy(); 60 | TelescopeLayout.cleanUp(this); 61 | } 62 | 63 | @Override public boolean onCreateOptionsMenu(Menu menu) { 64 | getMenuInflater().inflate(R.menu.sample_menu, menu); 65 | return true; 66 | } 67 | 68 | @Override public boolean onOptionsItemSelected(MenuItem item) { 69 | if (item.getItemId() == R.id.menu_attributions) { 70 | showAttributionsDialog(); 71 | return true; 72 | } 73 | 74 | return false; 75 | } 76 | 77 | private void showAttributionsDialog() { 78 | TextView attributionsView = 79 | (TextView) getLayoutInflater().inflate(R.layout.attributions_view, null); 80 | attributionsView.setText(Html.fromHtml(getString(R.string.attributions))); 81 | attributionsView.setMovementMethod(new LinkMovementMethod()); 82 | 83 | new AlertDialog.Builder(this).setTitle("Attributions") 84 | .setView(attributionsView) 85 | .setNegativeButton("Close", new DialogInterface.OnClickListener() { 86 | @Override public void onClick(DialogInterface dialog, int which) { 87 | dialog.cancel(); 88 | } 89 | }) 90 | .show(); 91 | } 92 | 93 | static class Adapter extends PagerAdapter { 94 | private final Context context; 95 | private final LayoutInflater inflater; 96 | private final List layouts = new ArrayList<>(); 97 | private final List titles = new ArrayList<>(); 98 | 99 | public Adapter(Context context) { 100 | this.context = context; 101 | inflater = LayoutInflater.from(context); 102 | } 103 | 104 | public void addView(@LayoutRes int layoutResId, @StringRes int titleResId) { 105 | layouts.add(layoutResId); 106 | titles.add(context.getString(titleResId)); 107 | } 108 | 109 | @Override public int getCount() { 110 | return layouts.size(); 111 | } 112 | 113 | @Override public CharSequence getPageTitle(int position) { 114 | return titles.get(position); 115 | } 116 | 117 | @Override public Object instantiateItem(ViewGroup container, int position) { 118 | View view = inflater.inflate(layouts.get(position), container, false); 119 | container.addView(view); 120 | return view; 121 | } 122 | 123 | @Override public void destroyItem(ViewGroup container, int position, Object object) { 124 | container.removeView((View) object); 125 | } 126 | 127 | @Override public boolean isViewFromObject(View view, Object object) { 128 | return view == object; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/ui/OtherTargetView.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.util.AttributeSet; 5 | import com.mattprecious.telescope.sample.R; 6 | 7 | public class OtherTargetView extends SampleEmailView { 8 | public OtherTargetView(Context context, AttributeSet attrs) { 9 | super(context, attrs); 10 | } 11 | 12 | @Override protected void onFinishInflate() { 13 | super.onFinishInflate(); 14 | // Parent handles the injection, don't bother. 15 | 16 | telescopeView.setScreenshotChildrenOnly(true); 17 | telescopeView.setScreenshotTarget(findViewById(R.id.target)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/ui/SampleAdditionalAttachmentEmailView.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.net.Uri; 5 | import android.util.AttributeSet; 6 | import android.widget.FrameLayout; 7 | import com.mattprecious.telescope.EmailLens; 8 | import com.mattprecious.telescope.TelescopeLayout; 9 | import com.mattprecious.telescope.sample.R; 10 | import java.io.File; 11 | import java.io.FileOutputStream; 12 | import java.io.IOException; 13 | import java.util.Collections; 14 | import java.util.Set; 15 | 16 | public class SampleAdditionalAttachmentEmailView extends FrameLayout { 17 | private static final String SHAKESPEARE = "A glooming peace this morning with it brings;\n" 18 | + "The sun, for sorrow, will not show his head:\n" 19 | + "Go hence, to have more talk of these sad things;\n" 20 | + "Some shall be pardon'd, and some punished:\n" 21 | + "For never was a story of more woe\n" 22 | + "Than this of Juliet and her Romeo."; 23 | 24 | public SampleAdditionalAttachmentEmailView(Context context, AttributeSet attrs) { 25 | super(context, attrs); 26 | } 27 | 28 | @Override protected void onFinishInflate() { 29 | super.onFinishInflate(); 30 | 31 | File filesDir = getContext().getExternalFilesDir(null); 32 | final File file = new File(filesDir, "shakespeare.txt"); 33 | FileOutputStream out = null; 34 | try { 35 | out = new FileOutputStream(file); 36 | out.write(SHAKESPEARE.getBytes()); 37 | } catch (java.io.IOException e) { 38 | throw new RuntimeException(e); 39 | } finally { 40 | if (out != null) { 41 | try { 42 | out.close(); 43 | } catch (IOException ignored) { 44 | } 45 | } 46 | } 47 | 48 | TelescopeLayout telescopeView = findViewById(R.id.telescope); 49 | telescopeView.setLens( 50 | new EmailLens(getContext(), "Bug report", "bugs@example.com") { 51 | @Override protected Set getAdditionalAttachments() { 52 | // TODO: This should be using a FileProvider. 53 | return Collections.singleton(Uri.fromFile(file)); 54 | } 55 | } 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/ui/SampleEmailDeviceInfoView.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.util.AttributeSet; 5 | import android.widget.FrameLayout; 6 | import com.mattprecious.telescope.EmailDeviceInfoLens; 7 | import com.mattprecious.telescope.TelescopeLayout; 8 | import com.mattprecious.telescope.sample.R; 9 | 10 | public class SampleEmailDeviceInfoView extends FrameLayout { 11 | public SampleEmailDeviceInfoView(Context context, AttributeSet attrs) { 12 | super(context, attrs); 13 | } 14 | 15 | @Override protected void onFinishInflate() { 16 | super.onFinishInflate(); 17 | 18 | TelescopeLayout telescopeView = findViewById(R.id.telescope); 19 | telescopeView.setLens(new EmailDeviceInfoLens(getContext(), "Bug report", "bugs@example.com")); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/ui/SampleEmailView.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.util.AttributeSet; 5 | import android.widget.FrameLayout; 6 | import com.mattprecious.telescope.EmailLens; 7 | import com.mattprecious.telescope.TelescopeLayout; 8 | import com.mattprecious.telescope.sample.R; 9 | 10 | public class SampleEmailView extends FrameLayout { 11 | protected TelescopeLayout telescopeView; 12 | 13 | public SampleEmailView(Context context, AttributeSet attrs) { 14 | super(context, attrs); 15 | } 16 | 17 | @Override protected void onFinishInflate() { 18 | super.onFinishInflate(); 19 | telescopeView = findViewById(R.id.telescope); 20 | telescopeView.setLens(new EmailLens(getContext(), "Bug report", "bugs@example.com")); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/ui/SampleMapsView.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.graphics.Bitmap; 5 | import android.graphics.Canvas; 6 | import android.graphics.Paint; 7 | import android.graphics.PorterDuff; 8 | import android.graphics.PorterDuffXfermode; 9 | import android.graphics.Rect; 10 | import android.util.AttributeSet; 11 | import android.widget.FrameLayout; 12 | import androidx.annotation.NonNull; 13 | import com.google.android.gms.maps.GoogleMap; 14 | import com.google.android.gms.maps.MapView; 15 | import com.google.android.gms.maps.OnMapReadyCallback; 16 | import com.mattprecious.telescope.BitmapProcessorListener; 17 | import com.mattprecious.telescope.EmailLens; 18 | import com.mattprecious.telescope.TelescopeLayout; 19 | import com.mattprecious.telescope.sample.R; 20 | 21 | public class SampleMapsView extends FrameLayout { 22 | public SampleMapsView(Context context, AttributeSet attrs) { 23 | super(context, attrs); 24 | } 25 | 26 | @Override protected void onFinishInflate() { 27 | super.onFinishInflate(); 28 | 29 | MapView mapView = findViewById(R.id.map); 30 | 31 | // Necessary to make MapView work. 32 | mapView.onCreate(null); 33 | mapView.onResume(); 34 | 35 | TelescopeLayout telescopeView = findViewById(R.id.telescope); 36 | telescopeView.setLens(new MapsEmailLens(getContext(), mapView)); 37 | } 38 | 39 | static class MapsEmailLens extends EmailLens { 40 | 41 | final MapView mapView; 42 | 43 | public MapsEmailLens(Context context, MapView mapView) { 44 | super(context, "Bug report", "bugs@example.com"); 45 | this.mapView = mapView; 46 | } 47 | 48 | @Override public void onCapture(final Bitmap originalBitmap, 49 | @NonNull final BitmapProcessorListener bitmapProcessorListener) { 50 | mapView.getMapAsync(new OnMapReadyCallback() { 51 | @Override public void onMapReady(GoogleMap googleMap) { 52 | googleMap.snapshot(new GoogleMap.SnapshotReadyCallback() { 53 | @Override public void onSnapshotReady(Bitmap snapshot) { 54 | int[] location = new int[2]; 55 | mapView.getLocationOnScreen(location); 56 | 57 | Bitmap bmOverlay = mergeBitmaps(originalBitmap, snapshot, location); 58 | 59 | bitmapProcessorListener.onBitmapReady(bmOverlay); 60 | } 61 | }); 62 | } 63 | }); 64 | } 65 | 66 | @NonNull private Bitmap mergeBitmaps(Bitmap background, Bitmap overlay, int[] overlayLocation) { 67 | final int width = background.getWidth(); 68 | final int height = background.getHeight(); 69 | int left = overlayLocation[0]; 70 | int top = overlayLocation[1]; 71 | 72 | Bitmap bmOverlay = Bitmap.createBitmap(width, height, background.getConfig()); 73 | Canvas canvas = new Canvas(bmOverlay); 74 | 75 | Paint paint = new Paint(); 76 | paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER)); 77 | 78 | canvas.drawBitmap(background, 0, 0, paint); 79 | canvas.drawBitmap(overlay, null, 80 | new Rect(left, top, left + overlay.getWidth(), top + overlay.getHeight()), paint); 81 | return bmOverlay; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /telescope-sample/src/main/java/com/mattprecious/telescope/sample/ui/SampleToastView.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.util.AttributeSet; 5 | import android.widget.FrameLayout; 6 | import android.widget.Toast; 7 | import com.mattprecious.telescope.Lens; 8 | import com.mattprecious.telescope.TelescopeLayout; 9 | import com.mattprecious.telescope.sample.R; 10 | import java.io.File; 11 | 12 | public class SampleToastView extends FrameLayout { 13 | public SampleToastView(Context context, AttributeSet attrs) { 14 | super(context, attrs); 15 | } 16 | 17 | @Override protected void onFinishInflate() { 18 | super.onFinishInflate(); 19 | 20 | TelescopeLayout telescopeView = findViewById(R.id.telescope); 21 | telescopeView.setLens(new Lens() { 22 | @Override public void onCapture(File screenshot) { 23 | Toast.makeText(getContext(), "Captured!", Toast.LENGTH_SHORT).show(); 24 | } 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/drawable/three_finger_press.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/drawable/two_finger_press.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/drawable/two_finger_press_styled.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/additional_attachment_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 16 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/attributions_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/children_only_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 14 | 18 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/default_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 16 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/device_info_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 16 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/maps_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 15 | 20 | 26 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/other_target_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 14 | 20 | 24 | 31 | 32 | 33 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/sample_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 13 | 19 | 26 | 27 | 33 | 34 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/styled_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 14 | 18 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/layout/three_finger_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 14 | 18 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/menu/sample_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /telescope-sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /telescope-sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /telescope-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /telescope-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/telescope/25fce65e06ba8c84ed5ef9e4cb8e674c2b9bbc01/telescope-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /telescope-sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Telescope Sample 3 | Telescope 4 | 5 | Default 6 | Device Info 7 | Styled 8 | Children Only 9 | Three Finger 10 | Other Target 11 | Additional Attachment 12 | Maps 13 | 14 | 15 | The provided email launcher can be configured to attach another file. In this example, we 16 | attach a text file with some Shakespeare in it because we\'re classy like that. 17 | 18 | 19 | Long press with two fingers to capture a bug report. An email will be pre-populated with an 20 | address and a subject. A screenshot will also be taken and automatically attached to the email 21 | for you. 22 | 23 | 24 | The provided email launcher can be configured to populate the body of the email as well. In this 25 | example, the body is filled with the app version and information about the current device. 26 | 27 | 28 | The color of the bars are easily configured to match your theme. 29 | 30 | 31 | By default, Telescope takes a screenshot of the entire window it is in. If you prefer, Telescope 32 | can be easily configured to only take a screenshot of the children inside the TelescopeLayout. 33 | 34 | 35 | Some of your views may already use two-finger gestures. Telescope can be configured to require 36 | any number of fingers to trigger. In this example, a three-finger press is required. 37 | 38 | 39 | Telescope can be configured to take a screenshot of a different view. In this example, long 40 | press this container to take a screenshot of the view below. 41 | 42 | 43 | Google Maps use two-finger gestures for pan and zooming. Three-finger press is required for this 44 | example. 45 | 46 | 47 | 49 | Tap-And-Hold Icons 50 | (1, 51 | 2) 52 |
53 | 54 | by Gary Lim from 55 | The Noun Project 56 | 57 | ]]>
58 |
59 | -------------------------------------------------------------------------------- /telescope-sample/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /telescope/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'com.vanniktech.maven.publish' 3 | 4 | android { 5 | namespace 'com.mattprecious.telescope' 6 | 7 | resourcePrefix 'telescope_' 8 | 9 | lintOptions { 10 | textOutput 'stdout' 11 | textReport true 12 | } 13 | } 14 | 15 | dependencies { 16 | implementation libs.androidx.annotations 17 | } 18 | -------------------------------------------------------------------------------- /telescope/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=Telescope 2 | POM_ARTIFACT_ID=telescope 3 | POM_PACKAGING=aar 4 | -------------------------------------------------------------------------------- /telescope/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 22 | 26 | 27 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/BitmapProcessorListener.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.graphics.Bitmap; 4 | import androidx.annotation.Nullable; 5 | 6 | /** 7 | * Interface definition for a callback to be invoked when additional processing on the screenshot 8 | * has been completed. 9 | */ 10 | public interface BitmapProcessorListener { 11 | /** Called when additional processing on the screenshot has been completed. */ 12 | void onBitmapReady(@Nullable Bitmap screenshot); 13 | } 14 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/EmailDeviceInfoLens.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.content.Context; 4 | import android.content.pm.PackageInfo; 5 | import android.content.pm.PackageManager; 6 | import android.os.Build; 7 | import android.util.DisplayMetrics; 8 | import android.util.Log; 9 | import java.util.Collections; 10 | import java.util.LinkedHashMap; 11 | import java.util.Map; 12 | 13 | /** 14 | * A basic {@link Lens} implementation that composes an email with the provided addresses and 15 | * subject (optional). The body will be pre-populated with app and device info: 16 | * 17 | *
    18 | *
  • App version
  • 19 | *
  • App version code
  • 20 | *
  • Device manufacturer
  • 21 | *
  • Device model
  • 22 | *
  • Screen resolution
  • 23 | *
  • Screen density
  • 24 | *
  • Android version
  • 25 | *
  • Android API level
  • 26 | *
27 | */ 28 | public class EmailDeviceInfoLens extends EmailLens { 29 | private static final String TAG = "EmailDeviceInfoLens"; 30 | 31 | private final Context context; 32 | private final String version; 33 | private final String versionCode; 34 | 35 | /** 36 | * @deprecated Use {@link #EmailDeviceInfoLens(Context, String, String...)} or {@link 37 | * #EmailDeviceInfoLens(Context, String, String, int, String...)}. 38 | */ 39 | @Deprecated 40 | public EmailDeviceInfoLens(Context context, String[] addresses, String subject, String version, 41 | int versionCode) { 42 | this(context, subject, version, versionCode, addresses); 43 | } 44 | 45 | public EmailDeviceInfoLens(Context context, String subject, String... addresses) { 46 | super(context, subject, addresses); 47 | this.context = context; 48 | 49 | PackageInfo packageInfo = null; 50 | try { 51 | packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); 52 | } catch (PackageManager.NameNotFoundException e) { 53 | Log.e(TAG, "Unable to get app info"); 54 | } 55 | 56 | if (packageInfo == null) { 57 | version = "0"; 58 | versionCode = String.valueOf(0); 59 | } else { 60 | version = packageInfo.versionName; 61 | versionCode = String.valueOf(packageInfo.versionCode); 62 | } 63 | } 64 | 65 | public EmailDeviceInfoLens(Context context, String subject, String version, int versionCode, 66 | String... addresses) { 67 | super(context, subject, addresses); 68 | this.context = context; 69 | this.version = version; 70 | this.versionCode = String.valueOf(versionCode); 71 | } 72 | 73 | @Override protected String getBody() { 74 | DisplayMetrics dm = context.getResources().getDisplayMetrics(); 75 | String densityBucket = getDensityString(dm); 76 | 77 | Map info = new LinkedHashMap<>(); 78 | info.put("Version", version); 79 | info.put("Version code", versionCode); 80 | info.put("Make", Build.MANUFACTURER); 81 | info.put("Model", Build.MODEL); 82 | info.put("Resolution", dm.heightPixels + "x" + dm.widthPixels); 83 | info.put("Density", dm.densityDpi + "dpi (" + densityBucket + ")"); 84 | info.put("Release", Build.VERSION.RELEASE); 85 | info.put("API", String.valueOf(Build.VERSION.SDK_INT)); 86 | info.putAll(getInfo()); 87 | 88 | StringBuilder builder = new StringBuilder(); 89 | for (Map.Entry entry : info.entrySet()) { 90 | builder.append(entry.getKey()).append(": ").append(entry.getValue()).append('\n'); 91 | } 92 | 93 | return builder.append("-------------------\n\n").toString(); // 94 | } 95 | 96 | /** 97 | * Pairs of additional data to be included in the email body. Called every time a new email is 98 | * created. 99 | */ 100 | protected Map getInfo() { 101 | return Collections.emptyMap(); 102 | } 103 | 104 | public static String getDensityString(DisplayMetrics displayMetrics) { 105 | switch (displayMetrics.densityDpi) { 106 | case DisplayMetrics.DENSITY_LOW: 107 | return "ldpi"; 108 | case DisplayMetrics.DENSITY_MEDIUM: 109 | return "mdpi"; 110 | case DisplayMetrics.DENSITY_TV: 111 | return "tvdpi"; 112 | case DisplayMetrics.DENSITY_HIGH: 113 | return "hdpi"; 114 | case DisplayMetrics.DENSITY_260: 115 | case DisplayMetrics.DENSITY_280: 116 | case DisplayMetrics.DENSITY_300: 117 | case DisplayMetrics.DENSITY_XHIGH: 118 | return "xhdpi"; 119 | case DisplayMetrics.DENSITY_340: 120 | case DisplayMetrics.DENSITY_360: 121 | case DisplayMetrics.DENSITY_400: 122 | case DisplayMetrics.DENSITY_420: 123 | case DisplayMetrics.DENSITY_XXHIGH: 124 | return "xxhdpi"; 125 | case DisplayMetrics.DENSITY_560: 126 | case DisplayMetrics.DENSITY_XXXHIGH: 127 | return "xxxhdpi"; 128 | default: 129 | return "unknown"; 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/EmailLens.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.content.ActivityNotFoundException; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.net.Uri; 7 | import android.os.AsyncTask; 8 | import android.widget.Toast; 9 | import androidx.annotation.WorkerThread; 10 | import java.io.File; 11 | import java.util.ArrayList; 12 | import java.util.Collections; 13 | import java.util.Set; 14 | 15 | /** 16 | *

17 | * A basic {@link Lens} implementation that composes an email with the provided addresses and 18 | * subject (optional). 19 | *

20 | * 21 | *

The {@link #getBody()} method can be overridden to pre-populate the body of the email.

22 | */ 23 | public class EmailLens extends Lens { 24 | private final Context context; 25 | private final String subject; 26 | private final String[] addresses; 27 | 28 | /** @deprecated Use {@link #EmailLens(Context, String, String...)}. */ 29 | @Deprecated 30 | public EmailLens(Context context, String[] addresses, String subject) { 31 | this(context, subject, addresses); 32 | } 33 | 34 | public EmailLens(Context context, String subject, String... addresses) { 35 | this.context = context; 36 | this.addresses = addresses == null ? null : addresses.clone(); 37 | this.subject = subject; 38 | } 39 | 40 | /** Create the email body. */ 41 | @WorkerThread protected String getBody() { 42 | return null; 43 | } 44 | 45 | @Override public void onCapture(File screenshot) { 46 | new CreateIntentTask(context, screenshot).execute(); 47 | } 48 | 49 | @WorkerThread protected Set getAdditionalAttachments() { 50 | return Collections.emptySet(); 51 | } 52 | 53 | private final class CreateIntentTask extends AsyncTask { 54 | private final Context context; 55 | private final File screenshot; 56 | 57 | CreateIntentTask(Context context, File screenshot) { 58 | this.context = context; 59 | this.screenshot = screenshot; 60 | } 61 | 62 | @Override protected Intent doInBackground(Void... params) { 63 | Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); 64 | intent.setType("message/rfc822"); 65 | 66 | if (subject != null) { 67 | intent.putExtra(Intent.EXTRA_SUBJECT, subject); 68 | } 69 | 70 | if (addresses != null) { 71 | intent.putExtra(Intent.EXTRA_EMAIL, addresses); 72 | } 73 | 74 | String body = getBody(); 75 | if (body != null) { 76 | intent.putExtra(Intent.EXTRA_TEXT, body); 77 | } 78 | 79 | Set additionalAttachments = getAdditionalAttachments(); 80 | ArrayList attachments = new ArrayList<>(additionalAttachments.size() + 1 /* screen */); 81 | if (!additionalAttachments.isEmpty()) { 82 | attachments.addAll(additionalAttachments); 83 | } 84 | if (screenshot != null) { 85 | attachments.add(TelescopeFileProvider.getUriForFile(context, screenshot)); 86 | } 87 | 88 | if (!attachments.isEmpty()) { 89 | intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments); 90 | intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 91 | } 92 | 93 | return intent; 94 | } 95 | 96 | @Override protected void onPostExecute(Intent intent) { 97 | try { 98 | context.startActivity(intent); 99 | } catch (ActivityNotFoundException e) { 100 | Toast.makeText(context, "\uD83D\uDD2D No email apps installed!", Toast.LENGTH_SHORT) 101 | .show(); 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/FileProvider.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2013 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* 18 | * A copy of support-v4's FileProvider that supports a new tag which uses 19 | * Context.getExternalFilesDir(null) as its root. 20 | * http://b.android.com/67171 and http://b.android.com/184603. 21 | */ 22 | 23 | package com.mattprecious.telescope; 24 | 25 | import android.content.ContentProvider; 26 | import android.content.ContentValues; 27 | import android.content.Context; 28 | import android.content.Intent; 29 | import android.content.pm.PackageManager; 30 | import android.content.pm.ProviderInfo; 31 | import android.content.res.XmlResourceParser; 32 | import android.database.Cursor; 33 | import android.database.MatrixCursor; 34 | import android.net.Uri; 35 | import android.os.Environment; 36 | import android.os.ParcelFileDescriptor; 37 | import android.provider.OpenableColumns; 38 | import android.text.TextUtils; 39 | import android.webkit.MimeTypeMap; 40 | import java.io.File; 41 | import java.io.FileNotFoundException; 42 | import java.io.IOException; 43 | import java.util.HashMap; 44 | import java.util.Map; 45 | import org.xmlpull.v1.XmlPullParserException; 46 | 47 | import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT; 48 | import static org.xmlpull.v1.XmlPullParser.START_TAG; 49 | 50 | /** 51 | * FileProvider is a special subclass of {@link ContentProvider} that facilitates secure sharing 52 | * of files associated with an app by creating a content:// {@link Uri} for a file 53 | * instead of a file:/// {@link Uri}. 54 | *

55 | * A content URI allows you to grant read and write access using 56 | * temporary access permissions. When you create an {@link Intent} containing 57 | * a content URI, in order to send the content URI 58 | * to a client app, you can also call {@link Intent#setFlags(int) Intent.setFlags()} to add 59 | * permissions. These permissions are available to the client app for as long as the stack for 60 | * a receiving {@link android.app.Activity} is active. For an {@link Intent} going to a 61 | * {@link android.app.Service}, the permissions are available as long as the 62 | * {@link android.app.Service} is running. 63 | *

64 | * In comparison, to control access to a file:/// {@link Uri} you have to modify the 65 | * file system permissions of the underlying file. The permissions you provide become available to 66 | * any app, and remain in effect until you change them. This level of access is 67 | * fundamentally insecure. 68 | *

69 | * The increased level of file access security offered by a content URI 70 | * makes FileProvider a key part of Android's security infrastructure. 71 | *

72 | * This overview of FileProvider includes the following topics: 73 | *

74 | *
    75 | *
  1. Defining a FileProvider
  2. 76 | *
  3. Specifying Available Files
  4. 77 | *
  5. Retrieving the Content URI for a File
  6. 78 | *
  7. Granting Temporary Permissions to a URI
  8. 79 | *
  9. Serving a Content URI to Another App
  10. 80 | *
81 | *

Defining a FileProvider

82 | *

83 | * Since the default functionality of FileProvider includes content URI generation for files, you 84 | * don't need to define a subclass in code. Instead, you can include a FileProvider in your app 85 | * by specifying it entirely in XML. To specify the FileProvider component itself, add a 86 | * <provider> 87 | * element to your app manifest. Set the android:name attribute to 88 | * android.support.v4.content.FileProvider. Set the android:authorities 89 | * attribute to a URI authority based on a domain you control; for example, if you control the 90 | * domain mydomain.com you should use the authority 91 | * com.mydomain.fileprovider. Set the android:exported attribute to 92 | * false; the FileProvider does not need to be public. Set the 93 | * android:grantUriPermissions attribute to true, to allow you 95 | * to grant temporary access to files. For example: 96 | *

 97 |  * <manifest>
 98 |  * ...
 99 |  * <application>
100 |  * ...
101 |  * <provider
102 |  * android:name="android.support.v4.content.FileProvider"
103 |  * android:authorities="com.mydomain.fileprovider"
104 |  * android:exported="false"
105 |  * android:grantUriPermissions="true">
106 |  * ...
107 |  * </provider>
108 |  * ...
109 |  * </application>
110 |  * </manifest>
111 | *

112 | * If you want to override any of the default behavior of FileProvider methods, extend 113 | * the FileProvider class and use the fully-qualified class name in the android:name 114 | * attribute of the <provider> element. 115 | *

Specifying Available Files

116 | * A FileProvider can only generate a content URI for files in directories that you specify 117 | * beforehand. To specify a directory, specify the its storage area and path in XML, using child 118 | * elements of the <paths> element. 119 | * For example, the following paths element tells FileProvider that you intend to 120 | * request content URIs for the images/ subdirectory of your private file area. 121 | *
122 |  * <paths xmlns:android="http://schemas.android.com/apk/res/android">
123 |  * <files-path name="my_images" path="images/"/>
124 |  * ...
125 |  * </paths>
126 |  * 
127 | *

128 | * The <paths> element must contain one or more of the following child elements: 129 | *

130 | *
131 | *
132 | *
133 |  * <files-path name="name" path="path" />
134 |  * 
135 | *
136 | *
137 | * Represents files in the files/ subdirectory of your app's internal storage 138 | * area. This subdirectory is the same as the value returned by {@link Context#getFilesDir() 139 | * Context.getFilesDir()}. 140 | *
141 | *
142 |  * <external-path name="name" path="path" />
143 |  * 
144 | *
145 | *
146 | * Represents files in the root of your app's external storage area. The path 147 | * {@link Context#getExternalFilesDir(String) Context.getExternalFilesDir()} returns the 148 | * files/ subdirectory of this this root. 149 | *
150 | *
151 | *
152 |  * <cache-path name="name" path="path" />
153 |  * 
154 | *
155 | *
156 | * Represents files in the cache subdirectory of your app's internal storage area. The root path 157 | * of this subdirectory is the same as the value returned by {@link Context#getCacheDir() 158 | * getCacheDir()}. 159 | *
160 | *
161 | *

162 | * These child elements all use the same attributes: 163 | *

164 | *
165 | *
166 | * name="name" 167 | *
168 | *
169 | * A URI path segment. To enforce security, this value hides the name of the subdirectory 170 | * you're sharing. The subdirectory name for this value is contained in the 171 | * path attribute. 172 | *
173 | *
174 | * path="path" 175 | *
176 | *
177 | * The subdirectory you're sharing. While the name attribute is a URI path 178 | * segment, the path value is an actual subdirectory name. Notice that the 179 | * value refers to a subdirectory, not an individual file or files. You can't 180 | * share a single file by its file name, nor can you specify a subset of files using 181 | * wildcards. 182 | *
183 | *
184 | *

185 | * You must specify a child element of <paths> for each directory that contains 186 | * files for which you want content URIs. For example, these XML elements specify two directories: 187 | *

188 |  * <paths xmlns:android="http://schemas.android.com/apk/res/android">
189 |  * <files-path name="my_images" path="images/"/>
190 |  * <files-path name="my_docs" path="docs/"/>
191 |  * </paths>
192 |  * 
193 | *

194 | * Put the <paths> element and its children in an XML file in your project. 195 | * For example, you can add them to a new file called res/xml/file_paths.xml. 196 | * To link this file to the FileProvider, add a 197 | * <meta-data> element 198 | * as a child of the <provider> element that defines the FileProvider. Set the 199 | * <meta-data> element's "android:name" attribute to 200 | * android.support.FILE_PROVIDER_PATHS. Set the element's "android:resource" attribute 201 | * to @xml/file_paths (notice that you don't specify the .xml 202 | * extension). For example: 203 | *

204 |  * <provider
205 |  * android:name="android.support.v4.content.FileProvider"
206 |  * android:authorities="com.mydomain.fileprovider"
207 |  * android:exported="false"
208 |  * android:grantUriPermissions="true">
209 |  * <meta-data
210 |  * android:name="android.support.FILE_PROVIDER_PATHS"
211 |  * android:resource="@xml/file_paths" />
212 |  * </provider>
213 |  * 
214 | *

Generating the Content URI for a File

215 | *

216 | * To share a file with another app using a content URI, your app has to generate the content URI. 217 | * To generate the content URI, create a new {@link File} for the file, then pass the {@link File} 218 | * to {@link #getUriForFile(Context, String, File) getUriForFile()}. You can send the content URI 219 | * returned by {@link #getUriForFile(Context, String, File) getUriForFile()} to another app in an 220 | * {@link android.content.Intent}. The client app that receives the content URI can open the file 221 | * and access its contents by calling 222 | * {@link android.content.ContentResolver#openFileDescriptor(Uri, String) 223 | * ContentResolver.openFileDescriptor} to get a {@link ParcelFileDescriptor}. 224 | *

225 | * For example, suppose your app is offering files to other apps with a FileProvider that has the 226 | * authority com.mydomain.fileprovider. To get a content URI for the file 227 | * default_image.jpg in the images/ subdirectory of your internal storage 228 | * add the following code: 229 | *

230 |  * File imagePath = new File(Context.getFilesDir(), "images");
231 |  * File newFile = new File(imagePath, "default_image.jpg");
232 |  * Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
233 |  * 
234 | * As a result of the previous snippet, 235 | * {@link #getUriForFile(Context, String, File) getUriForFile()} returns the content URI 236 | * content://com.mydomain.fileprovider/my_images/default_image.jpg. 237 | *

Granting Temporary Permissions to a URI

238 | * To grant an access permission to a content URI returned from 239 | * {@link #getUriForFile(Context, String, File) getUriForFile()}, do one of the following: 240 | *
    241 | *
  • 242 | * Call the method 243 | * {@link Context#grantUriPermission(String, Uri, int) 244 | * Context.grantUriPermission(package, Uri, mode_flags)} for the content:// 245 | * {@link Uri}, using the desired mode flags. This grants temporary access permission for the 246 | * content URI to the specified package, according to the value of the 247 | * the mode_flags parameter, which you can set to 248 | * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION}, {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} 249 | * or both. The permission remains in effect until you revoke it by calling 250 | * {@link Context#revokeUriPermission(Uri, int) revokeUriPermission()} or until the device 251 | * reboots. 252 | *
  • 253 | *
  • 254 | * Put the content URI in an {@link Intent} by calling {@link Intent#setData(Uri) setData()}. 255 | *
  • 256 | *
  • 257 | * Next, call the method {@link Intent#setFlags(int) Intent.setFlags()} with either 258 | * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} or 259 | * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} or both. 260 | *
  • 261 | *
  • 262 | * Finally, send the {@link Intent} to 263 | * another app. Most often, you do this by calling 264 | * {@link android.app.Activity#setResult(int, android.content.Intent) setResult()}. 265 | *

    266 | * Permissions granted in an {@link Intent} remain in effect while the stack of the receiving 267 | * {@link android.app.Activity} is active. When the stack finishes, the permissions are 268 | * automatically removed. Permissions granted to one {@link android.app.Activity} in a client 269 | * app are automatically extended to other components of that app. 270 | *

    271 | *
  • 272 | *
273 | *

Serving a Content URI to Another App

274 | *

275 | * There are a variety of ways to serve the content URI for a file to a client app. One common way 276 | * is for the client app to start your app by calling 277 | * {@link android.app.Activity#startActivityForResult(Intent, int, Bundle) startActivityResult()}, 278 | * which sends an {@link Intent} to your app to start an {@link android.app.Activity} in your app. 279 | * In response, your app can immediately return a content URI to the client app or present a user 280 | * interface that allows the user to pick a file. In the latter case, once the user picks the file 281 | * your app can return its content URI. In both cases, your app returns the content URI in an 282 | * {@link Intent} sent via {@link android.app.Activity#setResult(int, Intent) setResult()}. 283 | *

284 | *

285 | * You can also put the content URI in a {@link android.content.ClipData} object and then add the 286 | * object to an {@link Intent} you send to a client app. To do this, call 287 | * {@link Intent#setClipData(ClipData) Intent.setClipData()}. When you use this approach, you can 288 | * add multiple {@link android.content.ClipData} objects to the {@link Intent}, each with its own 289 | * content URI. When you call {@link Intent#setFlags(int) Intent.setFlags()} on the {@link Intent} 290 | * to set temporary access permissions, the same permissions are applied to all of the content 291 | * URIs. 292 | *

293 | *

294 | * Note: The {@link Intent#setClipData(ClipData) Intent.setClipData()} method is 295 | * only available in platform version 16 (Android 4.1) and later. If you want to maintain 296 | * compatibility with previous versions, you should send one content URI at a time in the 297 | * {@link Intent}. Set the action to {@link Intent#ACTION_SEND} and put the URI in data by calling 298 | * {@link Intent#setData setData()}. 299 | *

300 | *

More Information

301 | *

302 | * To learn more about FileProvider, see the Android training class 303 | * Sharing Files Securely with 304 | * URIs. 305 | *

306 | */ 307 | class FileProvider extends ContentProvider { 308 | private static final String[] COLUMNS = { 309 | OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE 310 | }; 311 | 312 | private static final String META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS"; 313 | 314 | private static final String TAG_ROOT_PATH = "root-path"; 315 | private static final String TAG_FILES_PATH = "files-path"; 316 | private static final String TAG_CACHE_PATH = "cache-path"; 317 | private static final String TAG_EXTERNAL = "external-path"; 318 | private static final String TAG_EXTERNAL_APP = "external-app-path"; 319 | 320 | private static final String ATTR_NAME = "name"; 321 | private static final String ATTR_PATH = "path"; 322 | 323 | private static final File DEVICE_ROOT = new File("/"); 324 | 325 | // @GuardedBy("sCache") 326 | private static HashMap sCache = new HashMap(); 327 | 328 | private PathStrategy mStrategy; 329 | 330 | /** 331 | * The default FileProvider implementation does not need to be initialized. If you want to 332 | * override this method, you must provide your own subclass of FileProvider. 333 | */ 334 | @Override public boolean onCreate() { 335 | return true; 336 | } 337 | 338 | /** 339 | * After the FileProvider is instantiated, this method is called to provide the system with 340 | * information about the provider. 341 | * 342 | * @param context A {@link Context} for the current component. 343 | * @param info A {@link ProviderInfo} for the new provider. 344 | */ 345 | @Override public void attachInfo(Context context, ProviderInfo info) { 346 | super.attachInfo(context, info); 347 | 348 | // Sanity check our security 349 | if (info.exported) { 350 | throw new SecurityException("Provider must not be exported"); 351 | } 352 | if (!info.grantUriPermissions) { 353 | throw new SecurityException("Provider must grant uri permissions"); 354 | } 355 | 356 | mStrategy = getPathStrategy(context, info.authority); 357 | } 358 | 359 | /** 360 | * Return a content URI for a given {@link File}. Specific temporary 361 | * permissions for the content URI can be set with 362 | * {@link Context#grantUriPermission(String, Uri, int)}, or added 363 | * to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then 364 | * {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are 365 | * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and 366 | * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a 367 | * content {@link Uri} for file paths defined in their <paths> 368 | * meta-data element. See the Class Overview for more information. 369 | * 370 | * @param context A {@link Context} for the current component. 371 | * @param authority The authority of a {@link FileProvider} defined in a 372 | * {@code <provider>} element in your app's manifest. 373 | * @param file A {@link File} pointing to the filename for which you want a 374 | * content {@link Uri}. 375 | * @return A content URI for the file. 376 | * @throws IllegalArgumentException When the given {@link File} is outside 377 | * the paths supported by the provider. 378 | */ 379 | protected static Uri getUriForFile(Context context, String authority, File file) { 380 | final PathStrategy strategy = getPathStrategy(context, authority); 381 | return strategy.getUriForFile(file); 382 | } 383 | 384 | /** 385 | * Use a content URI returned by 386 | * {@link #getUriForFile(Context, String, File) getUriForFile()} to get information about a file 387 | * managed by the FileProvider. 388 | * FileProvider reports the column names defined in {@link android.provider.OpenableColumns}: 389 | *
    390 | *
  • {@link android.provider.OpenableColumns#DISPLAY_NAME}
  • 391 | *
  • {@link android.provider.OpenableColumns#SIZE}
  • 392 | *
393 | * For more information, see 394 | * {@link ContentProvider#query(Uri, String[], String, String[], String) 395 | * ContentProvider.query()}. 396 | * 397 | * @param uri A content URI returned by {@link #getUriForFile}. 398 | * @param projection The list of columns to put into the {@link Cursor}. If null all columns are 399 | * included. 400 | * @param selection Selection criteria to apply. If null then all data that matches the content 401 | * URI is returned. 402 | * @param selectionArgs An array of {@link java.lang.String}, containing arguments to bind to 403 | * the selection parameter. The query method scans selection from left to 404 | * right and iterates through selectionArgs, replacing the current "?" character in 405 | * selection with the value at the current position in selectionArgs. The 406 | * values are bound to selection as {@link java.lang.String} values. 407 | * @param sortOrder A {@link java.lang.String} containing the column name(s) on which to sort 408 | * the resulting {@link Cursor}. 409 | * @return A {@link Cursor} containing the results of the query. 410 | */ 411 | @Override public Cursor query(Uri uri, String[] projection, String selection, 412 | String[] selectionArgs, String sortOrder) { 413 | // ContentProvider has already checked granted permissions 414 | final File file = mStrategy.getFileForUri(uri); 415 | 416 | if (projection == null) { 417 | projection = COLUMNS; 418 | } 419 | 420 | String[] cols = new String[projection.length]; 421 | Object[] values = new Object[projection.length]; 422 | int i = 0; 423 | for (String col : projection) { 424 | if (OpenableColumns.DISPLAY_NAME.equals(col)) { 425 | cols[i] = OpenableColumns.DISPLAY_NAME; 426 | values[i++] = file.getName(); 427 | } else if (OpenableColumns.SIZE.equals(col)) { 428 | cols[i] = OpenableColumns.SIZE; 429 | values[i++] = file.length(); 430 | } 431 | } 432 | 433 | cols = copyOf(cols, i); 434 | values = copyOf(values, i); 435 | 436 | final MatrixCursor cursor = new MatrixCursor(cols, 1); 437 | cursor.addRow(values); 438 | return cursor; 439 | } 440 | 441 | /** 442 | * Returns the MIME type of a content URI returned by 443 | * {@link #getUriForFile(Context, String, File) getUriForFile()}. 444 | * 445 | * @param uri A content URI returned by 446 | * {@link #getUriForFile(Context, String, File) getUriForFile()}. 447 | * @return If the associated file has an extension, the MIME type associated with that 448 | * extension; otherwise application/octet-stream. 449 | */ 450 | @Override public String getType(Uri uri) { 451 | // ContentProvider has already checked granted permissions 452 | final File file = mStrategy.getFileForUri(uri); 453 | 454 | final int lastDot = file.getName().lastIndexOf('.'); 455 | if (lastDot >= 0) { 456 | final String extension = file.getName().substring(lastDot + 1); 457 | final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 458 | if (mime != null) { 459 | return mime; 460 | } 461 | } 462 | 463 | return "application/octet-stream"; 464 | } 465 | 466 | /** 467 | * By default, this method throws an {@link java.lang.UnsupportedOperationException}. You must 468 | * subclass FileProvider if you want to provide different functionality. 469 | */ 470 | @Override public Uri insert(Uri uri, ContentValues values) { 471 | throw new UnsupportedOperationException("No external inserts"); 472 | } 473 | 474 | /** 475 | * By default, this method throws an {@link java.lang.UnsupportedOperationException}. You must 476 | * subclass FileProvider if you want to provide different functionality. 477 | */ 478 | @Override public int update(Uri uri, ContentValues values, String selection, 479 | String[] selectionArgs) { 480 | throw new UnsupportedOperationException("No external updates"); 481 | } 482 | 483 | /** 484 | * Deletes the file associated with the specified content URI, as 485 | * returned by {@link #getUriForFile(Context, String, File) getUriForFile()}. Notice that this 486 | * method does not throw an {@link java.io.IOException}; you must check its return value. 487 | * 488 | * @param uri A content URI for a file, as returned by 489 | * {@link #getUriForFile(Context, String, File) getUriForFile()}. 490 | * @param selection Ignored. Set to {@code null}. 491 | * @param selectionArgs Ignored. Set to {@code null}. 492 | * @return 1 if the delete succeeds; otherwise, 0. 493 | */ 494 | @Override public int delete(Uri uri, String selection, String[] selectionArgs) { 495 | // ContentProvider has already checked granted permissions 496 | final File file = mStrategy.getFileForUri(uri); 497 | return file.delete() ? 1 : 0; 498 | } 499 | 500 | /** 501 | * By default, FileProvider automatically returns the 502 | * {@link ParcelFileDescriptor} for a file associated with a content:// 503 | * {@link Uri}. To get the {@link ParcelFileDescriptor}, call 504 | * {@link android.content.ContentResolver#openFileDescriptor(Uri, String) 505 | * ContentResolver.openFileDescriptor}. 506 | * 507 | * To override this method, you must provide your own subclass of FileProvider. 508 | * 509 | * @param uri A content URI associated with a file, as returned by 510 | * {@link #getUriForFile(Context, String, File) getUriForFile()}. 511 | * @param mode Access mode for the file. May be "r" for read-only access, "rw" for read and 512 | * write access, or "rwt" for read and write access that truncates any existing file. 513 | * @return A new {@link ParcelFileDescriptor} with which you can access the file. 514 | */ 515 | @Override public ParcelFileDescriptor openFile(Uri uri, String mode) 516 | throws FileNotFoundException { 517 | // ContentProvider has already checked granted permissions 518 | final File file = mStrategy.getFileForUri(uri); 519 | final int fileMode = modeToMode(mode); 520 | return ParcelFileDescriptor.open(file, fileMode); 521 | } 522 | 523 | /** 524 | * Return {@link PathStrategy} for given authority, either by parsing or 525 | * returning from cache. 526 | */ 527 | private static PathStrategy getPathStrategy(Context context, String authority) { 528 | PathStrategy strat; 529 | synchronized (sCache) { 530 | strat = sCache.get(authority); 531 | if (strat == null) { 532 | try { 533 | strat = parsePathStrategy(context, authority); 534 | } catch (IOException e) { 535 | throw new IllegalArgumentException( 536 | "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e); 537 | } catch (XmlPullParserException e) { 538 | throw new IllegalArgumentException( 539 | "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e); 540 | } 541 | sCache.put(authority, strat); 542 | } 543 | } 544 | return strat; 545 | } 546 | 547 | /** 548 | * Parse and return {@link PathStrategy} for given authority as defined in 549 | * {@link #META_DATA_FILE_PROVIDER_PATHS} {@code <meta-data>}. 550 | * 551 | * @see #getPathStrategy(Context, String) 552 | */ 553 | private static PathStrategy parsePathStrategy(Context context, String authority) 554 | throws IOException, XmlPullParserException { 555 | final SimplePathStrategy strat = new SimplePathStrategy(authority); 556 | 557 | final ProviderInfo info = 558 | context.getPackageManager().resolveContentProvider(authority, PackageManager.GET_META_DATA); 559 | final XmlResourceParser in = 560 | info.loadXmlMetaData(context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS); 561 | if (in == null) { 562 | throw new IllegalArgumentException("Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data"); 563 | } 564 | 565 | int type; 566 | while ((type = in.next()) != END_DOCUMENT) { 567 | if (type == START_TAG) { 568 | final String tag = in.getName(); 569 | 570 | final String name = in.getAttributeValue(null, ATTR_NAME); 571 | String path = in.getAttributeValue(null, ATTR_PATH); 572 | 573 | File target = null; 574 | if (TAG_ROOT_PATH.equals(tag)) { 575 | target = buildPath(DEVICE_ROOT, path); 576 | } else if (TAG_FILES_PATH.equals(tag)) { 577 | target = buildPath(context.getFilesDir(), path); 578 | } else if (TAG_CACHE_PATH.equals(tag)) { 579 | target = buildPath(context.getCacheDir(), path); 580 | } else if (TAG_EXTERNAL.equals(tag)) { 581 | target = buildPath(Environment.getExternalStorageDirectory(), path); 582 | } else if (TAG_EXTERNAL_APP.equals(tag)) { 583 | target = buildPath(context.getExternalFilesDir(null), path); 584 | } 585 | 586 | if (target != null) { 587 | strat.addRoot(name, target); 588 | } 589 | } 590 | } 591 | 592 | return strat; 593 | } 594 | 595 | /** 596 | * Strategy for mapping between {@link File} and {@link Uri}. 597 | *

598 | * Strategies must be symmetric so that mapping a {@link File} to a 599 | * {@link Uri} and then back to a {@link File} points at the original 600 | * target. 601 | *

602 | * Strategies must remain consistent across app launches, and not rely on 603 | * dynamic state. This ensures that any generated {@link Uri} can still be 604 | * resolved if your process is killed and later restarted. 605 | * 606 | * @see SimplePathStrategy 607 | */ 608 | interface PathStrategy { 609 | /** 610 | * Return a {@link Uri} that represents the given {@link File}. 611 | */ 612 | public Uri getUriForFile(File file); 613 | 614 | /** 615 | * Return a {@link File} that represents the given {@link Uri}. 616 | */ 617 | public File getFileForUri(Uri uri); 618 | } 619 | 620 | /** 621 | * Strategy that provides access to files living under a narrow whitelist of 622 | * filesystem roots. It will throw {@link SecurityException} if callers try 623 | * accessing files outside the configured roots. 624 | *

625 | * For example, if configured with 626 | * {@code addRoot("myfiles", context.getFilesDir())}, then 627 | * {@code context.getFileStreamPath("foo.txt")} would map to 628 | * {@code content://myauthority/myfiles/foo.txt}. 629 | */ 630 | static class SimplePathStrategy implements PathStrategy { 631 | private final String mAuthority; 632 | private final HashMap mRoots = new HashMap(); 633 | 634 | public SimplePathStrategy(String authority) { 635 | mAuthority = authority; 636 | } 637 | 638 | /** 639 | * Add a mapping from a name to a filesystem root. The provider only offers 640 | * access to files that live under configured roots. 641 | */ 642 | public void addRoot(String name, File root) { 643 | if (TextUtils.isEmpty(name)) { 644 | throw new IllegalArgumentException("Name must not be empty"); 645 | } 646 | 647 | try { 648 | // Resolve to canonical path to keep path checking fast 649 | root = root.getCanonicalFile(); 650 | } catch (IOException e) { 651 | throw new IllegalArgumentException("Failed to resolve canonical path for " + root, e); 652 | } 653 | 654 | mRoots.put(name, root); 655 | } 656 | 657 | @Override public Uri getUriForFile(File file) { 658 | String path; 659 | try { 660 | path = file.getCanonicalPath(); 661 | } catch (IOException e) { 662 | throw new IllegalArgumentException("Failed to resolve canonical path for " + file); 663 | } 664 | 665 | // Find the most-specific root path 666 | Map.Entry mostSpecific = null; 667 | for (Map.Entry root : mRoots.entrySet()) { 668 | final String rootPath = root.getValue().getPath(); 669 | if (path.startsWith(rootPath) && (mostSpecific == null 670 | || rootPath.length() > mostSpecific.getValue().getPath().length())) { 671 | mostSpecific = root; 672 | } 673 | } 674 | 675 | if (mostSpecific == null) { 676 | throw new IllegalArgumentException("Failed to find configured root that contains " + path); 677 | } 678 | 679 | // Start at first char of path under root 680 | final String rootPath = mostSpecific.getValue().getPath(); 681 | if (rootPath.endsWith("/")) { 682 | path = path.substring(rootPath.length()); 683 | } else { 684 | path = path.substring(rootPath.length() + 1); 685 | } 686 | 687 | // Encode the tag and path separately 688 | path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/"); 689 | return new Uri.Builder().scheme("content").authority(mAuthority).encodedPath(path).build(); 690 | } 691 | 692 | @Override public File getFileForUri(Uri uri) { 693 | String path = uri.getEncodedPath(); 694 | 695 | final int splitIndex = path.indexOf('/', 1); 696 | final String tag = Uri.decode(path.substring(1, splitIndex)); 697 | path = Uri.decode(path.substring(splitIndex + 1)); 698 | 699 | final File root = mRoots.get(tag); 700 | if (root == null) { 701 | throw new IllegalArgumentException("Unable to find configured root for " + uri); 702 | } 703 | 704 | File file = new File(root, path); 705 | try { 706 | file = file.getCanonicalFile(); 707 | } catch (IOException e) { 708 | throw new IllegalArgumentException("Failed to resolve canonical path for " + file); 709 | } 710 | 711 | if (!file.getPath().startsWith(root.getPath())) { 712 | throw new SecurityException("Resolved path jumped beyond configured root"); 713 | } 714 | 715 | return file; 716 | } 717 | } 718 | 719 | /** 720 | * Copied from ContentResolver.java 721 | */ 722 | private static int modeToMode(String mode) { 723 | int modeBits; 724 | if ("r".equals(mode)) { 725 | modeBits = ParcelFileDescriptor.MODE_READ_ONLY; 726 | } else if ("w".equals(mode) || "wt".equals(mode)) { 727 | modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY 728 | | ParcelFileDescriptor.MODE_CREATE 729 | | ParcelFileDescriptor.MODE_TRUNCATE; 730 | } else if ("wa".equals(mode)) { 731 | modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY 732 | | ParcelFileDescriptor.MODE_CREATE 733 | | ParcelFileDescriptor.MODE_APPEND; 734 | } else if ("rw".equals(mode)) { 735 | modeBits = ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_CREATE; 736 | } else if ("rwt".equals(mode)) { 737 | modeBits = ParcelFileDescriptor.MODE_READ_WRITE 738 | | ParcelFileDescriptor.MODE_CREATE 739 | | ParcelFileDescriptor.MODE_TRUNCATE; 740 | } else { 741 | throw new IllegalArgumentException("Invalid mode: " + mode); 742 | } 743 | return modeBits; 744 | } 745 | 746 | private static File buildPath(File base, String... segments) { 747 | File cur = base; 748 | for (String segment : segments) { 749 | if (segment != null) { 750 | cur = new File(cur, segment); 751 | } 752 | } 753 | return cur; 754 | } 755 | 756 | private static String[] copyOf(String[] original, int newLength) { 757 | final String[] result = new String[newLength]; 758 | System.arraycopy(original, 0, result, 0, newLength); 759 | return result; 760 | } 761 | 762 | private static Object[] copyOf(Object[] original, int newLength) { 763 | final Object[] result = new Object[newLength]; 764 | System.arraycopy(original, 0, result, 0, newLength); 765 | return result; 766 | } 767 | } 768 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/Lens.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.graphics.Bitmap; 4 | import androidx.annotation.NonNull; 5 | import androidx.annotation.Nullable; 6 | import java.io.File; 7 | 8 | /** 9 | * Interface definition for a callback to be invoked when a capture is triggered from a 10 | * {@link TelescopeLayout}. 11 | */ 12 | public abstract class Lens { 13 | 14 | /** 15 | * Called when a capture is triggered but not yet saved to a {@link File}, enabling additional 16 | * processing before saving. The default implementation immediately calls the {@code listener} 17 | * with the original screenshot. 18 | * 19 | * @param screenshot A reference to the screenshot that was captured. Can be null if screenshots 20 | * were disabled. 21 | * @param listener callback for when additional processing has been completed. This listener must 22 | * be called for the screenshot to be saved to disk. 23 | */ 24 | public void onCapture(@Nullable Bitmap screenshot, @NonNull BitmapProcessorListener listener) { 25 | listener.onBitmapReady(screenshot); 26 | } 27 | 28 | /** 29 | * Called when a capture is triggered and saved to a {@link File}. 30 | * 31 | * @param screenshot A reference to the screenshot that was captured. Can be null if screenshots 32 | * were disabled. 33 | */ 34 | public abstract void onCapture(@Nullable File screenshot); 35 | } 36 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/Preconditions.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2007 The Guava Authors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License 10 | * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 | * or implied. See the License for the specific language governing permissions and limitations under 12 | * the License. 13 | */ 14 | 15 | package com.mattprecious.telescope; 16 | 17 | final class Preconditions { 18 | /** 19 | * Ensures that an object reference passed as a parameter to the calling method is not null. 20 | * 21 | * @param reference an object reference 22 | * @param message exception message 23 | * @return the non-null reference that was validated 24 | * @throws NullPointerException if {@code reference} is null 25 | */ 26 | public static T checkNotNull(T reference, String message) { 27 | if (reference == null) { 28 | throw new NullPointerException(message); 29 | } 30 | 31 | return reference; 32 | } 33 | 34 | private Preconditions() { 35 | throw new AssertionError("No instances."); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/RequestCaptureActivity.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.annotation.TargetApi; 4 | import android.app.Activity; 5 | import android.content.Context; 6 | import android.content.Intent; 7 | import android.media.projection.MediaProjectionManager; 8 | import android.os.Build; 9 | import android.os.Bundle; 10 | import android.util.Log; 11 | 12 | /** A transparent activity used to request permission for screen capturing. */ 13 | public final class RequestCaptureActivity extends Activity { 14 | public static final String RESULT_EXTRA_CODE = "code"; 15 | public static final String RESULT_EXTRA_DATA = "data"; 16 | public static final String RESULT_EXTRA_PROMPT_SHOWN = "prompt-shown"; 17 | 18 | private static final String TAG = "TelescopeCapture"; 19 | private static final int REQUEST_CODE = 1; 20 | 21 | public static String getResultBroadcastAction(Context context) { 22 | return context.getPackageName() + ".telescope.CAPTURE"; 23 | } 24 | 25 | private long requestStartTime; 26 | 27 | @Override protected void onCreate(Bundle savedInstanceState) { 28 | super.onCreate(savedInstanceState); 29 | 30 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { 31 | Log.e(TAG, "System capture activity started pre-lollipop."); 32 | finish(); 33 | return; 34 | } 35 | 36 | requestCapture(); 37 | } 38 | 39 | @TargetApi(Build.VERSION_CODES.LOLLIPOP) private void requestCapture() { 40 | MediaProjectionManager projectionManager = 41 | (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); 42 | Intent intent = projectionManager.createScreenCaptureIntent(); 43 | 44 | requestStartTime = System.currentTimeMillis(); 45 | startActivityForResult(intent, REQUEST_CODE); 46 | } 47 | 48 | @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { 49 | if (requestCode == REQUEST_CODE) { 50 | Intent intent = new Intent(getResultBroadcastAction(this)); 51 | intent.putExtra(RESULT_EXTRA_CODE, resultCode); 52 | intent.putExtra(RESULT_EXTRA_DATA, data); 53 | intent.putExtra(RESULT_EXTRA_PROMPT_SHOWN, promptShown()); 54 | sendBroadcast(intent); 55 | finish(); 56 | return; 57 | } 58 | 59 | super.onActivityResult(requestCode, resultCode, data); 60 | } 61 | 62 | private boolean promptShown() { 63 | // Assume that the prompt was shown if the response took 200ms or more to return. 64 | return System.currentTimeMillis() - requestStartTime > 200; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/ScreenshotMode.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | // Keep in sync with attrs.xml. 4 | public enum ScreenshotMode { 5 | /** 6 | * Takes a native screenshot from the OS which includes system bars and keyboards. 7 | * 8 | *

9 | * System screenshots are only available on API 21+. Telescope will automatically fall back to 10 | * {@link #CANVAS} mode on earlier platforms or if screen recording permission was not granted. 11 | * {@link #CANVAS} will also be used if Telescope has been configured to screenshot children only 12 | * or if a different target view has been specified. 13 | * 14 | *

15 | * 16 | * Requires the 17 | * {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE} permission 18 | * on API 18 and below. 19 | * 20 | */ 21 | SYSTEM, 22 | /** 23 | * Uses the drawing cache of the target view to create a screenshot. 24 | * 25 | *

26 | * 27 | * Requires the 28 | * {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE} permission 29 | * on API 18 and below. 30 | * 31 | */ 32 | CANVAS, 33 | /** Do not save a screenshot. */ 34 | NONE, 35 | } 36 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/TelescopeFileProvider.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.content.Context; 4 | import android.net.Uri; 5 | import java.io.File; 6 | 7 | public final class TelescopeFileProvider extends FileProvider { 8 | /** 9 | * Calls {@link #getUriForFile(Context, String, File)} using the correct authority for Telescope 10 | * screenshots. 11 | */ 12 | public static Uri getUriForFile(Context context, File file) { 13 | return getUriForFile(context, context.getPackageName() + ".telescope.fileprovider", file); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/TelescopeLayout.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.animation.ValueAnimator; 4 | import android.annotation.SuppressLint; 5 | import android.annotation.TargetApi; 6 | import android.app.Activity; 7 | import android.content.BroadcastReceiver; 8 | import android.content.Context; 9 | import android.content.ContextWrapper; 10 | import android.content.Intent; 11 | import android.content.IntentFilter; 12 | import android.content.res.TypedArray; 13 | import android.graphics.Bitmap; 14 | import android.graphics.Canvas; 15 | import android.graphics.Paint; 16 | import android.graphics.PixelFormat; 17 | import android.hardware.display.DisplayManager; 18 | import android.hardware.display.VirtualDisplay; 19 | import android.media.Image; 20 | import android.media.ImageReader; 21 | import android.media.projection.MediaProjection; 22 | import android.media.projection.MediaProjectionManager; 23 | import android.os.AsyncTask; 24 | import android.os.Build; 25 | import android.os.Handler; 26 | import android.os.HandlerThread; 27 | import android.os.Process; 28 | import android.os.Vibrator; 29 | import android.util.AttributeSet; 30 | import android.util.DisplayMetrics; 31 | import android.util.Log; 32 | import android.view.MotionEvent; 33 | import android.view.PixelCopy; 34 | import android.view.Surface; 35 | import android.view.View; 36 | import android.view.Window; 37 | import android.view.WindowManager; 38 | import android.widget.FrameLayout; 39 | import androidx.annotation.ColorInt; 40 | import androidx.annotation.IntRange; 41 | import androidx.annotation.NonNull; 42 | import java.io.File; 43 | import java.io.FileNotFoundException; 44 | import java.io.FileOutputStream; 45 | import java.io.IOException; 46 | import java.nio.ByteBuffer; 47 | import java.text.SimpleDateFormat; 48 | import java.util.Date; 49 | import java.util.Locale; 50 | 51 | import static android.Manifest.permission.VIBRATE; 52 | import static android.animation.ValueAnimator.AnimatorUpdateListener; 53 | import static android.content.pm.PackageManager.PERMISSION_GRANTED; 54 | import static android.graphics.Paint.Style; 55 | import static android.os.Build.VERSION.SDK_INT; 56 | import static android.view.PixelCopy.SUCCESS; 57 | import static com.mattprecious.telescope.Preconditions.checkNotNull; 58 | 59 | /** 60 | * A layout used to take a screenshot and initiate a callback when the user long-presses the 61 | * container. 62 | */ 63 | public class TelescopeLayout extends FrameLayout { 64 | private static final String TAG = "Telescope"; 65 | static final SimpleDateFormat SCREENSHOT_FILE_FORMAT = 66 | new SimpleDateFormat("'telescope'-yyyy-MM-dd-HHmmss.'png'", Locale.US); 67 | private static final int PROGRESS_STROKE_DP = 4; 68 | private static final long CANCEL_DURATION_MS = 250; 69 | private static final long DONE_DURATION_MS = 1000; 70 | private static final long TRIGGER_DURATION_MS = 1000; 71 | private static final long VIBRATION_DURATION_MS = 50; 72 | 73 | private static final int DEFAULT_POINTER_COUNT = 2; 74 | private static final int DEFAULT_PROGRESS_COLOR = 0xff2196f3; 75 | 76 | private static Handler backgroundHandler; 77 | 78 | final MediaProjectionManager projectionManager; 79 | final WindowManager windowManager; 80 | private final Vibrator vibrator; 81 | private final Handler handler = new Handler(); 82 | private final Runnable trigger = this::trigger; 83 | private final IntentFilter requestCaptureFilter; 84 | private final BroadcastReceiver requestCaptureReceiver; 85 | private final IntentFilter serviceStartedFilter; 86 | private final BroadcastReceiver serviceStartedReceiver; 87 | 88 | private final float halfStrokeWidth; 89 | private final Paint progressPaint; 90 | private final ValueAnimator progressAnimator; 91 | private final ValueAnimator progressCancelAnimator; 92 | private final ValueAnimator doneAnimator; 93 | 94 | Lens lens; 95 | private View screenshotTarget; 96 | private int pointerCount; 97 | private ScreenshotMode screenshotMode; 98 | private boolean screenshotChildrenOnly; 99 | private boolean vibrate; 100 | 101 | // State. 102 | float progressFraction; 103 | float doneFraction; 104 | private boolean pressing; 105 | private boolean capturing; 106 | boolean saving; 107 | 108 | public TelescopeLayout(Context context) { 109 | this(context, null); 110 | } 111 | 112 | public TelescopeLayout(Context context, AttributeSet attrs) { 113 | this(context, attrs, 0); 114 | } 115 | 116 | public TelescopeLayout(Context context, AttributeSet attrs, int defStyle) { 117 | super(context, attrs, defStyle); 118 | setWillNotDraw(false); 119 | screenshotTarget = this; 120 | 121 | float density = context.getResources().getDisplayMetrics().density; 122 | halfStrokeWidth = PROGRESS_STROKE_DP * density / 2; 123 | 124 | TypedArray a = 125 | context.obtainStyledAttributes(attrs, R.styleable.telescope_TelescopeLayout, defStyle, 0); 126 | pointerCount = a.getInt(R.styleable.telescope_TelescopeLayout_telescope_pointerCount, 127 | DEFAULT_POINTER_COUNT); 128 | int progressColor = a.getColor(R.styleable.telescope_TelescopeLayout_telescope_progressColor, 129 | DEFAULT_PROGRESS_COLOR); 130 | screenshotMode = ScreenshotMode.values()[a.getInt( 131 | R.styleable.telescope_TelescopeLayout_telescope_screenshotMode, 132 | ScreenshotMode.SYSTEM.ordinal())]; 133 | screenshotChildrenOnly = 134 | a.getBoolean(R.styleable.telescope_TelescopeLayout_telescope_screenshotChildrenOnly, false); 135 | vibrate = a.getBoolean(R.styleable.telescope_TelescopeLayout_telescope_vibrate, true); 136 | a.recycle(); 137 | 138 | progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 139 | progressPaint.setColor(progressColor); 140 | progressPaint.setStrokeWidth(PROGRESS_STROKE_DP * density); 141 | progressPaint.setStyle(Style.STROKE); 142 | 143 | AnimatorUpdateListener progressUpdateListener = animation -> { 144 | progressFraction = (float) animation.getAnimatedValue(); 145 | invalidate(); 146 | }; 147 | 148 | progressAnimator = new ValueAnimator(); 149 | progressAnimator.setDuration(TRIGGER_DURATION_MS); 150 | progressAnimator.addUpdateListener(progressUpdateListener); 151 | 152 | progressCancelAnimator = new ValueAnimator(); 153 | progressCancelAnimator.setDuration(CANCEL_DURATION_MS); 154 | progressCancelAnimator.addUpdateListener(progressUpdateListener); 155 | 156 | doneFraction = 1; 157 | doneAnimator = ValueAnimator.ofFloat(0, 1); 158 | doneAnimator.setDuration(DONE_DURATION_MS); 159 | doneAnimator.addUpdateListener(animation -> { 160 | doneFraction = (float) animation.getAnimatedValue(); 161 | invalidate(); 162 | }); 163 | 164 | if (isInEditMode()) { 165 | projectionManager = null; 166 | windowManager = null; 167 | vibrator = null; 168 | requestCaptureFilter = null; 169 | requestCaptureReceiver = null; 170 | serviceStartedFilter = null; 171 | serviceStartedReceiver = null; 172 | return; 173 | } 174 | 175 | windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); 176 | vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); 177 | 178 | if (SDK_INT < 21) { 179 | projectionManager = null; 180 | requestCaptureFilter = null; 181 | requestCaptureReceiver = null; 182 | serviceStartedFilter = null; 183 | serviceStartedReceiver = null; 184 | } else { 185 | projectionManager = 186 | (MediaProjectionManager) context.getApplicationContext() 187 | .getSystemService(Context.MEDIA_PROJECTION_SERVICE); 188 | 189 | requestCaptureFilter = 190 | new IntentFilter(RequestCaptureActivity.getResultBroadcastAction(context)); 191 | requestCaptureReceiver = new BroadcastReceiver() { 192 | @TargetApi(21) @Override 193 | public void onReceive(Context context, Intent intent) { 194 | unregisterRequestCaptureReceiver(); 195 | 196 | int resultCode = intent.getIntExtra(RequestCaptureActivity.RESULT_EXTRA_CODE, 197 | Activity.RESULT_CANCELED); 198 | 199 | if (resultCode != Activity.RESULT_OK) { 200 | captureWindowScreenshot(); 201 | return; 202 | } 203 | 204 | // The service needs to be running before we start the projection and there's no guarantee 205 | // that it will have started once we return from startForegroundService. Rather than using 206 | // binders, we'll just bounce the data through another broadcast from the service. 207 | registerServiceStartedReceiver(); 208 | 209 | Intent data = intent.getParcelableExtra(RequestCaptureActivity.RESULT_EXTRA_DATA); 210 | startForegroundService(data); 211 | } 212 | }; 213 | 214 | serviceStartedFilter = 215 | new IntentFilter(TelescopeProjectionService.getStartedBroadcastAction(context)); 216 | serviceStartedReceiver = new BroadcastReceiver() { 217 | @TargetApi(21) @Override 218 | public void onReceive(Context context, Intent intent) { 219 | unregisterServiceStartedReceiver(); 220 | 221 | Intent data = intent.getParcelableExtra(TelescopeProjectionService.EXTRA_DATA); 222 | 223 | final MediaProjection mediaProjection = 224 | projectionManager.getMediaProjection(Activity.RESULT_OK, data); 225 | 226 | if (intent.getBooleanExtra(RequestCaptureActivity.RESULT_EXTRA_PROMPT_SHOWN, true)) { 227 | // Delay capture until after the permission dialog is gone. 228 | postDelayed(() -> captureNativeScreenshot(mediaProjection), 500); 229 | } else { 230 | captureNativeScreenshot(mediaProjection); 231 | } 232 | } 233 | }; 234 | } 235 | } 236 | 237 | /** 238 | * Delete the screenshot folder for this app. Be careful not to call this before any intents have 239 | * finished using a screenshot reference. 240 | */ 241 | public static void cleanUp(Context context) { 242 | File path = getScreenshotFolder(context); 243 | if (!path.exists()) { 244 | return; 245 | } 246 | 247 | delete(path); 248 | } 249 | 250 | /** Set the {@link Lens} to be called when the user triggers a capture. */ 251 | public void setLens(@NonNull Lens lens) { 252 | checkNotNull(lens, "lens == null"); 253 | this.lens = lens; 254 | } 255 | 256 | /** Set the number of pointers requires to trigger the capture. Default is 2. */ 257 | public void setPointerCount(@IntRange(from = 1) int pointerCount) { 258 | if (pointerCount < 1) { 259 | throw new IllegalArgumentException("pointerCount < 1"); 260 | } 261 | 262 | this.pointerCount = pointerCount; 263 | } 264 | 265 | /** Set the color of the progress bars. */ 266 | public void setProgressColor(@ColorInt int progressColor) { 267 | progressPaint.setColor(progressColor); 268 | } 269 | 270 | /** Sets the {@link ScreenshotMode} used to capture a screenshot. */ 271 | public void setScreenshotMode(@NonNull ScreenshotMode screenshotMode) { 272 | checkNotNull(screenshotMode, "screenshotMode == null"); 273 | this.screenshotMode = screenshotMode; 274 | } 275 | 276 | /** 277 | * Set whether the screenshot will capture the children of this view only, or if it will 278 | * capture the whole window this view is in. Default is false. 279 | */ 280 | public void setScreenshotChildrenOnly(boolean screenshotChildrenOnly) { 281 | this.screenshotChildrenOnly = screenshotChildrenOnly; 282 | } 283 | 284 | /** Set the target view that the screenshot will capture. */ 285 | public void setScreenshotTarget(@NonNull View screenshotTarget) { 286 | checkNotNull(screenshotTarget, "screenshotTarget == null"); 287 | this.screenshotTarget = screenshotTarget; 288 | } 289 | 290 | /** 291 | *

Set whether vibration is enabled when a capture is triggered. Default is true.

292 | * 293 | *

Requires the {@link android.Manifest.permission#VIBRATE} permission.

294 | */ 295 | public void setVibrate(boolean vibrate) { 296 | this.vibrate = vibrate; 297 | } 298 | 299 | @Override public boolean onInterceptTouchEvent(MotionEvent ev) { 300 | if (!isEnabled()) { 301 | return false; 302 | } 303 | 304 | // Capture all clicks while capturing/saving. 305 | if (capturing || saving) { 306 | return true; 307 | } 308 | 309 | if (ev.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN 310 | && ev.getPointerCount() == pointerCount) { 311 | // onTouchEvent isn't called if we steal focus from a child, so call start here. 312 | start(); 313 | 314 | // Steal the events from our children. 315 | return true; 316 | } 317 | 318 | return super.onInterceptTouchEvent(ev); 319 | } 320 | 321 | @Override public boolean onTouchEvent(MotionEvent event) { 322 | if (!isEnabled()) { 323 | return false; 324 | } 325 | 326 | // Capture all clicks while capturing/saving. 327 | if (capturing || saving) { 328 | return true; 329 | } 330 | 331 | switch (event.getActionMasked()) { 332 | case MotionEvent.ACTION_CANCEL: 333 | case MotionEvent.ACTION_UP: 334 | case MotionEvent.ACTION_POINTER_UP: 335 | if (pressing) { 336 | cancel(); 337 | } 338 | 339 | return false; 340 | case MotionEvent.ACTION_DOWN: 341 | if (!pressing && event.getPointerCount() == pointerCount) { 342 | start(); 343 | } 344 | return true; 345 | case MotionEvent.ACTION_POINTER_DOWN: 346 | if (event.getPointerCount() == pointerCount) { 347 | // There's a few cases where we'll get called called in both onInterceptTouchEvent and 348 | // here, so make sure we only start once. 349 | if (!pressing) { 350 | start(); 351 | } 352 | return true; 353 | } else { 354 | cancel(); 355 | } 356 | break; 357 | case MotionEvent.ACTION_MOVE: 358 | if (pressing) { 359 | invalidate(); 360 | return true; 361 | } 362 | break; 363 | } 364 | 365 | return super.onTouchEvent(event); 366 | } 367 | 368 | @Override public void draw(Canvas canvas) { 369 | super.draw(canvas); 370 | 371 | // Do not draw any bars while we're capturing a screenshot. 372 | if (capturing) { 373 | return; 374 | } 375 | 376 | int width = getMeasuredWidth(); 377 | int height = getMeasuredHeight(); 378 | 379 | if (progressFraction > 0) { 380 | // Top (left to right). 381 | canvas.drawLine(0, halfStrokeWidth, width * progressFraction, halfStrokeWidth, progressPaint); 382 | // Right (top to bottom). 383 | canvas.drawLine(width - halfStrokeWidth, 0, width - halfStrokeWidth, 384 | height * progressFraction, progressPaint); 385 | // Bottom (right to left). 386 | canvas.drawLine(width, height - halfStrokeWidth, width - (width * progressFraction), 387 | height - halfStrokeWidth, progressPaint); 388 | // Left (bottom to top). 389 | canvas.drawLine(halfStrokeWidth, height, halfStrokeWidth, 390 | height - (height * progressFraction), progressPaint); 391 | } 392 | 393 | if (doneFraction < 1) { 394 | // Top (left to right). 395 | canvas.drawLine(width * doneFraction, halfStrokeWidth, width, halfStrokeWidth, progressPaint); 396 | // Right (top to bottom). 397 | canvas.drawLine(width - halfStrokeWidth, height * doneFraction, width - halfStrokeWidth, 398 | height, progressPaint); 399 | // Bottom (right to left). 400 | canvas.drawLine(width - (width * doneFraction), height - halfStrokeWidth, 0, 401 | height - halfStrokeWidth, progressPaint); 402 | // Left (bottom to top). 403 | canvas.drawLine(halfStrokeWidth, height - (height * doneFraction), halfStrokeWidth, 0, 404 | progressPaint); 405 | } 406 | } 407 | 408 | private void start() { 409 | pressing = true; 410 | progressAnimator.setFloatValues(progressFraction, 1); 411 | progressAnimator.start(); 412 | handler.postDelayed(trigger, TRIGGER_DURATION_MS); 413 | } 414 | 415 | private void stop() { 416 | pressing = false; 417 | } 418 | 419 | private void cancel() { 420 | stop(); 421 | progressAnimator.cancel(); 422 | progressCancelAnimator.setFloatValues(progressFraction, 0); 423 | progressCancelAnimator.start(); 424 | handler.removeCallbacks(trigger); 425 | } 426 | 427 | void trigger() { 428 | stop(); 429 | 430 | vibrateIfNecessary(); 431 | 432 | switch (screenshotMode) { 433 | case SYSTEM: 434 | if (projectionManager != null 435 | && shouldCaptureWholeWindow() 436 | && !windowHasSecureFlag()) { 437 | // Take a full screenshot of the device. Request permission first. 438 | registerRequestCaptureReceiver(); 439 | getContext().startActivity(new Intent(getContext(), RequestCaptureActivity.class)); 440 | break; 441 | } 442 | 443 | // System was requested but isn't supported. Fall through. 444 | case CANVAS: 445 | captureWindowScreenshot(); 446 | break; 447 | case NONE: 448 | doneAnimator.start(); 449 | new SaveScreenshotTask(null).execute(); 450 | break; 451 | default: 452 | throw new IllegalStateException("Unknown screenshot mode: " + screenshotMode); 453 | } 454 | } 455 | 456 | @SuppressLint("MissingPermission") 457 | private void vibrateIfNecessary() { 458 | if (vibrate && hasVibratePermission(getContext())) { 459 | vibrator.vibrate(VIBRATION_DURATION_MS); 460 | } 461 | } 462 | 463 | private boolean shouldCaptureWholeWindow() { 464 | return !screenshotChildrenOnly && screenshotTarget == this; 465 | } 466 | 467 | private boolean windowHasSecureFlag() { 468 | // Find an activity. 469 | Context context = getContext(); 470 | while (!(context instanceof Activity) && context instanceof ContextWrapper) { 471 | context = ((ContextWrapper) context).getBaseContext(); 472 | } 473 | 474 | //noinspection SimplifiableIfStatement 475 | if (context instanceof Activity) { 476 | return (((Activity) context).getWindow().getAttributes().flags 477 | & WindowManager.LayoutParams.FLAG_SECURE) != 0; 478 | } 479 | 480 | // If we can't find an activity, return true so we fall back to canvas screenshots. 481 | return true; 482 | } 483 | 484 | void checkLens() { 485 | if (lens == null) { 486 | throw new IllegalStateException("Must call setLens() before capturing a screenshot."); 487 | } 488 | } 489 | 490 | private void captureWindowScreenshot() { 491 | capturingStart(); 492 | 493 | // Wait for the next frame to be sure our progress bars are hidden. 494 | post(() -> { 495 | View view = getTargetView(); 496 | Window window = findWindow(); 497 | if (Build.VERSION.SDK_INT >= 26 && shouldCaptureWholeWindow() && window != null) { 498 | Bitmap screenshot = Bitmap.createBitmap(window.peekDecorView().getWidth(), 499 | window.peekDecorView().getHeight(), Bitmap.Config.ARGB_8888); 500 | PixelCopy.request(window, screenshot, copyResult -> { 501 | if (copyResult == SUCCESS) { 502 | finishCanvasScreenshot(screenshot); 503 | } else { 504 | Log.e( 505 | TAG, 506 | "Failed to capture window screenshot (" + copyResult + "). Falling back to canvas." 507 | ); 508 | captureCanvasScreenshot(view); 509 | } 510 | }, handler); 511 | } else { 512 | captureCanvasScreenshot(view); 513 | } 514 | }); 515 | } 516 | 517 | private void captureCanvasScreenshot(View view) { 518 | view.setDrawingCacheEnabled(true); 519 | Bitmap screenshot = Bitmap.createBitmap(view.getDrawingCache()); 520 | view.setDrawingCacheEnabled(false); 521 | finishCanvasScreenshot(screenshot); 522 | } 523 | 524 | private void finishCanvasScreenshot(Bitmap screenshot) { 525 | capturingEnd(); 526 | checkLens(); 527 | lens.onCapture(screenshot, processed -> new SaveScreenshotTask(processed).execute()); 528 | } 529 | 530 | private void capturingStart() { 531 | progressAnimator.end(); 532 | progressFraction = 0; 533 | 534 | capturing = true; 535 | invalidate(); 536 | } 537 | 538 | void capturingEnd() { 539 | capturing = false; 540 | doneAnimator.start(); 541 | } 542 | 543 | /** 544 | * Unless {@code screenshotChildrenOnly} is true, navigate up the layout hierarchy until we find 545 | * the root view. 546 | */ 547 | View getTargetView() { 548 | View view = screenshotTarget; 549 | if (!screenshotChildrenOnly) { 550 | while (view.getRootView() != view) { 551 | view = view.getRootView(); 552 | } 553 | } 554 | 555 | return view; 556 | } 557 | 558 | private Window findWindow() { 559 | Context c = getContext(); 560 | while (true) { 561 | if (c instanceof Activity) { 562 | return ((Activity) c).getWindow(); 563 | } 564 | 565 | if (c instanceof ContextWrapper) { 566 | c = ((ContextWrapper) c).getBaseContext(); 567 | } else { 568 | return null; 569 | } 570 | } 571 | } 572 | 573 | 574 | /** Recursive delete of a file or directory. */ 575 | private static void delete(File file) { 576 | if (file.isDirectory()) { 577 | File[] files = file.listFiles(); 578 | if (files != null) { 579 | for (File child : files) { 580 | delete(child); 581 | } 582 | } 583 | } 584 | 585 | file.delete(); 586 | } 587 | 588 | static File getScreenshotFolder(Context context) { 589 | return new File(context.getExternalFilesDir(null), "telescope"); 590 | } 591 | 592 | private static boolean hasVibratePermission(Context context) { 593 | return context.checkPermission(VIBRATE, Process.myPid(), Process.myUid()) == PERMISSION_GRANTED; 594 | } 595 | 596 | /** 597 | * Save a screenshot to external storage, start the done animation, and call the capture 598 | * listener. 599 | */ 600 | private class SaveScreenshotTask extends AsyncTask { 601 | private final Context context; 602 | private final Bitmap screenshot; 603 | private String fileName; 604 | 605 | SaveScreenshotTask(Bitmap screenshot) { 606 | this.context = getContext(); 607 | this.screenshot = screenshot; 608 | } 609 | 610 | @Override protected void onPreExecute() { 611 | saving = true; 612 | fileName = SCREENSHOT_FILE_FORMAT.format(new Date()); 613 | } 614 | 615 | @Override protected File doInBackground(Void... params) { 616 | if (screenshot == null) { 617 | return null; 618 | } 619 | 620 | File screenshotFolder = getScreenshotFolder(context); 621 | if (!screenshotFolder.exists() && !screenshotFolder.mkdirs()) { 622 | Log.e(TAG, 623 | "Failed to save screenshot. Is the WRITE_EXTERNAL_STORAGE permission requested?"); 624 | return null; 625 | } 626 | 627 | File file = new File(screenshotFolder, fileName); 628 | FileOutputStream out; 629 | try { 630 | out = new FileOutputStream(file); 631 | } catch (FileNotFoundException e) { 632 | throw new AssertionError(e); 633 | } 634 | try { 635 | screenshot.compress(Bitmap.CompressFormat.PNG, 100, out); 636 | out.flush(); 637 | return file; 638 | } catch (IOException e) { 639 | Log.e(TAG, "Failed to save screenshot."); 640 | } finally { 641 | try { 642 | out.close(); 643 | } catch (IOException ignored) { 644 | } 645 | } 646 | 647 | return null; 648 | } 649 | 650 | @Override protected void onPostExecute(File screenshot) { 651 | saving = false; 652 | stopForegroundService(); 653 | 654 | checkLens(); 655 | lens.onCapture(screenshot); 656 | } 657 | } 658 | 659 | private void registerRequestCaptureReceiver() { 660 | if (SDK_INT >= 33) { 661 | getContext().registerReceiver(requestCaptureReceiver, requestCaptureFilter, 662 | Context.RECEIVER_EXPORTED); 663 | } else { 664 | getContext().registerReceiver(requestCaptureReceiver, requestCaptureFilter); 665 | } 666 | } 667 | 668 | private void registerServiceStartedReceiver() { 669 | if (SDK_INT >= 33) { 670 | getContext().registerReceiver(serviceStartedReceiver, serviceStartedFilter, 671 | Context.RECEIVER_EXPORTED); 672 | } else { 673 | getContext().registerReceiver(serviceStartedReceiver, serviceStartedFilter); 674 | } 675 | } 676 | 677 | void unregisterRequestCaptureReceiver() { 678 | getContext().unregisterReceiver(requestCaptureReceiver); 679 | } 680 | 681 | void unregisterServiceStartedReceiver() { 682 | getContext().unregisterReceiver(serviceStartedReceiver); 683 | } 684 | 685 | private void startForegroundService(Intent data) { 686 | if (SDK_INT >= 29) { 687 | // Starting from SDK 29, media projections require a foreground service 688 | // see https://github.com/mattprecious/telescope/issues/75 689 | 690 | Intent serviceIntent = new Intent(getContext(), TelescopeProjectionService.class); 691 | serviceIntent.putExtra(TelescopeProjectionService.EXTRA_DATA, data); 692 | getContext().startForegroundService(serviceIntent); 693 | } 694 | } 695 | 696 | private void stopForegroundService() { 697 | if (SDK_INT >= 29) { 698 | // Starting from SDK 29, media projections require a foreground service 699 | // see https://github.com/mattprecious/telescope/issues/75 700 | 701 | Intent serviceIntent = new Intent(getContext(), TelescopeProjectionService.class); 702 | getContext().stopService(serviceIntent); 703 | } 704 | } 705 | 706 | static Handler getBackgroundHandler() { 707 | if (backgroundHandler == null) { 708 | HandlerThread backgroundThread = 709 | new HandlerThread("telescope", Process.THREAD_PRIORITY_BACKGROUND); 710 | backgroundThread.start(); 711 | backgroundHandler = new Handler(backgroundThread.getLooper()); 712 | } 713 | 714 | return backgroundHandler; 715 | } 716 | 717 | @TargetApi(21) void captureNativeScreenshot(final MediaProjection projection) { 718 | capturingStart(); 719 | 720 | // Wait for the next frame to be sure our progress bars are hidden. 721 | post(() -> { 722 | DisplayMetrics displayMetrics = new DisplayMetrics(); 723 | windowManager.getDefaultDisplay().getRealMetrics(displayMetrics); 724 | final int width = displayMetrics.widthPixels; 725 | final int height = displayMetrics.heightPixels; 726 | 727 | @SuppressLint("WrongConstant") 728 | ImageReader imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2); 729 | Surface surface = imageReader.getSurface(); 730 | 731 | MediaProjectionCallback callback = new MediaProjectionCallback(imageReader, surface); 732 | projection.registerCallback(callback, null); 733 | 734 | callback.setDisplay( 735 | projection.createVirtualDisplay("telescope", width, height, displayMetrics.densityDpi, 736 | DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION, surface, null, null) 737 | ); 738 | 739 | imageReader.setOnImageAvailableListener(reader -> { 740 | Bitmap bitmap = null; 741 | try (Image image = reader.acquireLatestImage()) { 742 | post(this::capturingEnd); 743 | 744 | if (image == null) { 745 | return; 746 | } 747 | 748 | saving = true; 749 | 750 | Image.Plane[] planes = image.getPlanes(); 751 | ByteBuffer buffer = planes[0].getBuffer(); 752 | int pixelStride = planes[0].getPixelStride(); 753 | int rowStride = planes[0].getRowStride(); 754 | int rowPadding = rowStride - pixelStride * width; 755 | 756 | bitmap = Bitmap.createBitmap(width + rowPadding / pixelStride, height, 757 | Bitmap.Config.ARGB_8888); 758 | bitmap.copyPixelsFromBuffer(buffer); 759 | 760 | // Trim the screenshot to the correct size. 761 | final Bitmap croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height); 762 | 763 | checkLens(); 764 | lens.onCapture(croppedBitmap, 765 | processed -> new SaveScreenshotTask(croppedBitmap).execute()); 766 | } catch (UnsupportedOperationException e) { 767 | Log.e(TAG, 768 | "Failed to capture system screenshot. Setting the screenshot mode to CANVAS.", e); 769 | setScreenshotMode(ScreenshotMode.CANVAS); 770 | post(this::captureWindowScreenshot); 771 | } finally { 772 | if (bitmap != null) { 773 | bitmap.recycle(); 774 | } 775 | 776 | // Even though we're closing the reader in MediaProjectionCallback, we also need to close 777 | // it here. The callback is invoked asynchronously, which means we can receive another 778 | // image before the reader is closed. 779 | imageReader.close(); 780 | 781 | projection.stop(); 782 | } 783 | }, getBackgroundHandler()); 784 | }); 785 | } 786 | 787 | @TargetApi(21) 788 | private static class MediaProjectionCallback extends MediaProjection.Callback { 789 | private final ImageReader reader; 790 | private final Surface surface; 791 | private VirtualDisplay display = null; 792 | 793 | public MediaProjectionCallback(ImageReader reader, Surface surface) { 794 | this.reader = reader; 795 | this.surface = surface; 796 | } 797 | 798 | public void setDisplay(VirtualDisplay display) { 799 | this.display = display; 800 | } 801 | 802 | @Override 803 | public void onStop() { 804 | reader.close(); 805 | surface.release(); 806 | if (display != null) { 807 | display.release(); 808 | } 809 | } 810 | } 811 | } 812 | -------------------------------------------------------------------------------- /telescope/src/main/java/com/mattprecious/telescope/TelescopeProjectionService.java: -------------------------------------------------------------------------------- 1 | package com.mattprecious.telescope; 2 | 3 | import android.annotation.TargetApi; 4 | import android.app.Notification; 5 | import android.app.NotificationChannel; 6 | import android.app.NotificationManager; 7 | import android.app.Service; 8 | import android.content.Context; 9 | import android.content.Intent; 10 | import android.os.IBinder; 11 | import androidx.annotation.Nullable; 12 | 13 | import static android.os.Build.VERSION_CODES.Q; 14 | 15 | @TargetApi(Q) 16 | public class TelescopeProjectionService extends Service { 17 | public static final String EXTRA_DATA = "data"; 18 | public static final String NOTIFICATION_CHANNEL_ID = "Telescope Notifications"; 19 | public static final int SERVICE_ID = NOTIFICATION_CHANNEL_ID.hashCode(); 20 | 21 | public static String getStartedBroadcastAction(Context context) { 22 | return context.getPackageName() + ".telescope.SERVICE_STARTED"; 23 | } 24 | 25 | @Override public void onCreate() { 26 | super.onCreate(); 27 | 28 | createNotificationChannel(); 29 | startForeground( 30 | SERVICE_ID, 31 | new Notification.Builder(this, NOTIFICATION_CHANNEL_ID) 32 | .setSmallIcon(R.drawable.telescope_service) 33 | .build() 34 | ); 35 | } 36 | 37 | @Override 38 | public int onStartCommand(Intent intent, int flags, int startId) { 39 | if (!intent.hasExtra(EXTRA_DATA)) { 40 | throw new IllegalArgumentException("Service was started without extra: " + EXTRA_DATA); 41 | } 42 | 43 | Intent broadcastIntent = new Intent(getStartedBroadcastAction(this)); 44 | broadcastIntent.putExtra(EXTRA_DATA, (Intent) intent.getParcelableExtra(EXTRA_DATA)); 45 | sendBroadcast(broadcastIntent); 46 | 47 | return super.onStartCommand(broadcastIntent, flags, startId); 48 | } 49 | 50 | private void createNotificationChannel() { 51 | NotificationChannel serviceChannel = new NotificationChannel( 52 | NOTIFICATION_CHANNEL_ID, 53 | "Telescope", 54 | NotificationManager.IMPORTANCE_MIN 55 | ); 56 | 57 | NotificationManager notificationManager = getSystemService(NotificationManager.class); 58 | notificationManager.createNotificationChannel(serviceChannel); 59 | } 60 | 61 | @Nullable @Override public IBinder onBind(Intent intent) { 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /telescope/src/main/res/drawable/telescope_service.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /telescope/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /telescope/src/main/res/values/public.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /telescope/src/main/res/xml/telescope_file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | --------------------------------------------------------------------------------