├── .github
└── workflows
│ └── android.yml
├── .gitignore
├── LICENSE
├── README.md
├── README_OLD.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── assets
│ ├── asset1.wav
│ └── ringtones
│ │ └── asset2.mp3
│ ├── java
│ └── xyz
│ │ └── aprildown
│ │ └── ultimateringtonepicker
│ │ └── app
│ │ ├── MainActivity.kt
│ │ └── Toasts.kt
│ └── res
│ ├── drawable
│ └── ic_launcher_foreground.xml
│ ├── layout
│ └── activity_main.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-mdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── raw
│ ├── default_ringtone.mp3
│ └── short_message.mp3
│ └── values
│ ├── colors.xml
│ ├── strings.xml
│ └── styles.xml
├── art
├── activity.webp
├── dark.webp
├── dialog.webp
└── ic_launcher-web.webp
├── build.gradle
├── dependencies.gradle
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── library
├── .gitignore
├── build.gradle
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── xyz
│ │ └── aprildown
│ │ └── ultimateringtonepicker
│ │ ├── RingtonePickerActivity.kt
│ │ ├── RingtonePickerDialog.kt
│ │ ├── RingtonePickerFragment.kt
│ │ ├── RingtonePickerViewModel.kt
│ │ ├── UltimateRingtonePicker.kt
│ │ ├── Utils.kt
│ │ ├── data
│ │ ├── CustomRingtone.kt
│ │ ├── CustomRingtoneDAO.kt
│ │ ├── CustomRingtoneModel.kt
│ │ ├── DeviceRingtoneModel.kt
│ │ ├── Models.kt
│ │ ├── SystemRingtoneModel.kt
│ │ └── folder
│ │ │ ├── RingtoneFolderRetriever.kt
│ │ │ ├── RingtoneFolderRetrieverCompat.kt
│ │ │ ├── RingtoneFolderRetrieverPreQ.kt
│ │ │ └── RingtoneFolderRetrieverQ.kt
│ │ ├── music
│ │ └── AsyncRingtonePlayer.kt
│ │ └── ui
│ │ ├── CategoryFragment.kt
│ │ ├── DeviceRingtoneFragment.kt
│ │ ├── EventHandler.kt
│ │ ├── RecyclerViewUtils.kt
│ │ ├── RingtoneFragment.kt
│ │ ├── SystemRingtoneFragment.kt
│ │ ├── VisibleAddCustom.kt
│ │ ├── VisibleCategory.kt
│ │ ├── VisibleEmptyView.kt
│ │ ├── VisibleRingtone.kt
│ │ └── VisibleSection.kt
│ └── res
│ ├── anim-v22
│ └── urp_ringtone_active_animation_interpolator.xml
│ ├── animator-v22
│ ├── urp_ringtone_active_outlines_0_animation.xml
│ ├── urp_ringtone_active_outlines_1_animation.xml
│ └── urp_ringtone_active_outlines_2_animation.xml
│ ├── drawable-v22
│ └── urp_ringtone_active_animated.xml
│ ├── drawable
│ ├── urp_add_custom.xml
│ ├── urp_broken_ringtone.xml
│ ├── urp_custom_music.xml
│ ├── urp_ringtone_active_static.xml
│ ├── urp_ringtone_background.xml
│ ├── urp_ringtone_normal.xml
│ ├── urp_ringtone_selected.xml
│ └── urp_ringtone_silent.xml
│ ├── layout
│ ├── urp_activity_ringtone_picker.xml
│ ├── urp_dialog.xml
│ ├── urp_empty.xml
│ ├── urp_fragment_device_ringtone.xml
│ ├── urp_horizontal_divider.xml
│ ├── urp_recycler_view.xml
│ ├── urp_ringtone.xml
│ ├── urp_section.xml
│ └── urp_two_lines.xml
│ ├── navigation
│ └── urp_nav_graph.xml
│ ├── values-de
│ └── strings.xml
│ ├── values-es
│ └── strings.xml
│ ├── values-fr
│ └── strings.xml
│ ├── values-hi
│ └── strings.xml
│ ├── values-ja
│ └── strings.xml
│ ├── values-night
│ └── colors.xml
│ ├── values-nl
│ └── strings.xml
│ ├── values-pl
│ └── strings.xml
│ ├── values-v22
│ └── drawable.xml
│ ├── values-zh-rCN
│ └── strings.xml
│ ├── values-zh-rHK
│ └── strings.xml
│ ├── values-zh-rTW
│ └── strings.xml
│ └── values
│ ├── colors.xml
│ ├── drawable.xml
│ ├── ids.xml
│ ├── public.xml
│ ├── strings.xml
│ └── styles.xml
└── settings.gradle
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v3
16 | - name: Set up JDK 17
17 | uses: actions/setup-java@v3
18 | with:
19 | java-version: '17'
20 | distribution: 'temurin'
21 | cache: gradle
22 |
23 | - name: Grant execute permission for gradlew
24 | run: chmod +x gradlew
25 | - name: Build with Gradle
26 | run: ./gradlew build
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/java,linux,macos,gradle,kotlin,windows,android,jetbrains,jetbrains+iml,jetbrains+all,androidstudio
3 | # Edit at https://www.gitignore.io/?templates=java,linux,macos,gradle,kotlin,windows,android,jetbrains,jetbrains+iml,jetbrains+all,androidstudio
4 |
5 | ### Android ###
6 | # Built application files
7 | *.apk
8 | *.ap_
9 | *.aab
10 |
11 | # Files for the ART/Dalvik VM
12 | *.dex
13 |
14 | # Java class files
15 | *.class
16 |
17 | # Generated files
18 | bin/
19 | gen/
20 | out/
21 | release/
22 |
23 | # Gradle files
24 | .gradle/
25 | build/
26 |
27 | # Local configuration file (sdk path, etc)
28 | local.properties
29 |
30 | # Proguard folder generated by Eclipse
31 | proguard/
32 |
33 | # Log Files
34 | *.log
35 |
36 | # Android Studio Navigation editor temp files
37 | .navigation/
38 |
39 | # Android Studio captures folder
40 | captures/
41 |
42 | # IntelliJ
43 | *.iml
44 | .idea/workspace.xml
45 | .idea/tasks.xml
46 | .idea/gradle.xml
47 | .idea/assetWizardSettings.xml
48 | .idea/dictionaries
49 | .idea/libraries
50 | # Android Studio 3 in .gitignore file.
51 | .idea/caches
52 | .idea/modules.xml
53 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
54 | .idea/navEditor.xml
55 |
56 | # Keystore files
57 | # Uncomment the following lines if you do not want to check your keystore files in.
58 | #*.jks
59 | #*.keystore
60 |
61 | # External native build folder generated in Android Studio 2.2 and later
62 | .externalNativeBuild
63 |
64 | # Google Services (e.g. APIs or Firebase)
65 | # google-services.json
66 |
67 | # Freeline
68 | freeline.py
69 | freeline/
70 | freeline_project_description.json
71 |
72 | # fastlane
73 | fastlane/report.xml
74 | fastlane/Preview.html
75 | fastlane/screenshots
76 | fastlane/test_output
77 | fastlane/readme.md
78 |
79 | # Version control
80 | vcs.xml
81 |
82 | # lint
83 | lint/intermediates/
84 | lint/generated/
85 | lint/outputs/
86 | lint/tmp/
87 | # lint/reports/
88 |
89 | ### Android Patch ###
90 | gen-external-apklibs
91 | output.json
92 |
93 | ### Java ###
94 | # Compiled class file
95 |
96 | # Log file
97 |
98 | # BlueJ files
99 | *.ctxt
100 |
101 | # Mobile Tools for Java (J2ME)
102 | .mtj.tmp/
103 |
104 | # Package Files #
105 | *.jar
106 | *.war
107 | *.nar
108 | *.ear
109 | *.zip
110 | *.tar.gz
111 | *.rar
112 |
113 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
114 | hs_err_pid*
115 |
116 | ### JetBrains ###
117 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
118 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
119 |
120 | # User-specific stuff
121 | .idea/**/workspace.xml
122 | .idea/**/tasks.xml
123 | .idea/**/usage.statistics.xml
124 | .idea/**/dictionaries
125 | .idea/**/shelf
126 |
127 | # Generated files
128 | .idea/**/contentModel.xml
129 |
130 | # Sensitive or high-churn files
131 | .idea/**/dataSources/
132 | .idea/**/dataSources.ids
133 | .idea/**/dataSources.local.xml
134 | .idea/**/sqlDataSources.xml
135 | .idea/**/dynamic.xml
136 | .idea/**/uiDesigner.xml
137 | .idea/**/dbnavigator.xml
138 |
139 | # Gradle
140 | .idea/**/gradle.xml
141 | .idea/**/libraries
142 |
143 | # Gradle and Maven with auto-import
144 | # When using Gradle or Maven with auto-import, you should exclude module files,
145 | # since they will be recreated, and may cause churn. Uncomment if using
146 | # auto-import.
147 | # .idea/modules.xml
148 | # .idea/*.iml
149 | # .idea/modules
150 | # *.iml
151 | # *.ipr
152 |
153 | # CMake
154 | cmake-build-*/
155 |
156 | # Mongo Explorer plugin
157 | .idea/**/mongoSettings.xml
158 |
159 | # File-based project format
160 | *.iws
161 |
162 | # IntelliJ
163 |
164 | # mpeltonen/sbt-idea plugin
165 | .idea_modules/
166 |
167 | # JIRA plugin
168 | atlassian-ide-plugin.xml
169 |
170 | # Cursive Clojure plugin
171 | .idea/replstate.xml
172 |
173 | # Crashlytics plugin (for Android Studio and IntelliJ)
174 | com_crashlytics_export_strings.xml
175 | crashlytics.properties
176 | crashlytics-build.properties
177 | fabric.properties
178 |
179 | # Editor-based Rest Client
180 | .idea/httpRequests
181 |
182 | # Android studio 3.1+ serialized cache file
183 | .idea/caches/build_file_checksums.ser
184 |
185 | ### JetBrains Patch ###
186 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
187 |
188 | # *.iml
189 | # modules.xml
190 | # .idea/misc.xml
191 | # *.ipr
192 |
193 | # Sonarlint plugin
194 | .idea/sonarlint
195 |
196 | ### JetBrains+all ###
197 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
198 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
199 |
200 | # User-specific stuff
201 |
202 | # Generated files
203 |
204 | # Sensitive or high-churn files
205 |
206 | # Gradle
207 |
208 | # Gradle and Maven with auto-import
209 | # When using Gradle or Maven with auto-import, you should exclude module files,
210 | # since they will be recreated, and may cause churn. Uncomment if using
211 | # auto-import.
212 | # .idea/modules.xml
213 | # .idea/*.iml
214 | # .idea/modules
215 | # *.iml
216 | # *.ipr
217 |
218 | # CMake
219 |
220 | # Mongo Explorer plugin
221 |
222 | # File-based project format
223 |
224 | # IntelliJ
225 |
226 | # mpeltonen/sbt-idea plugin
227 |
228 | # JIRA plugin
229 |
230 | # Cursive Clojure plugin
231 |
232 | # Crashlytics plugin (for Android Studio and IntelliJ)
233 |
234 | # Editor-based Rest Client
235 |
236 | # Android studio 3.1+ serialized cache file
237 |
238 | ### JetBrains+all Patch ###
239 | # Ignores the whole .idea folder and all .iml files
240 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
241 |
242 | .idea/
243 |
244 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
245 |
246 | modules.xml
247 | .idea/misc.xml
248 | *.ipr
249 |
250 | # Sonarlint plugin
251 |
252 | ### JetBrains+iml ###
253 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
254 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
255 |
256 | # User-specific stuff
257 |
258 | # Generated files
259 |
260 | # Sensitive or high-churn files
261 |
262 | # Gradle
263 |
264 | # Gradle and Maven with auto-import
265 | # When using Gradle or Maven with auto-import, you should exclude module files,
266 | # since they will be recreated, and may cause churn. Uncomment if using
267 | # auto-import.
268 | # .idea/modules.xml
269 | # .idea/*.iml
270 | # .idea/modules
271 | # *.iml
272 | # *.ipr
273 |
274 | # CMake
275 |
276 | # Mongo Explorer plugin
277 |
278 | # File-based project format
279 |
280 | # IntelliJ
281 |
282 | # mpeltonen/sbt-idea plugin
283 |
284 | # JIRA plugin
285 |
286 | # Cursive Clojure plugin
287 |
288 | # Crashlytics plugin (for Android Studio and IntelliJ)
289 |
290 | # Editor-based Rest Client
291 |
292 | # Android studio 3.1+ serialized cache file
293 |
294 | ### JetBrains+iml Patch ###
295 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
296 |
297 |
298 | ### Kotlin ###
299 | # Compiled class file
300 |
301 | # Log file
302 |
303 | # BlueJ files
304 |
305 | # Mobile Tools for Java (J2ME)
306 |
307 | # Package Files #
308 |
309 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
310 |
311 | ### Linux ###
312 | *~
313 |
314 | # temporary files which can be created if a process still has a handle open of a deleted file
315 | .fuse_hidden*
316 |
317 | # KDE directory preferences
318 | .directory
319 |
320 | # Linux trash folder which might appear on any partition or disk
321 | .Trash-*
322 |
323 | # .nfs files are created when an open file is removed but is still being accessed
324 | .nfs*
325 |
326 | ### macOS ###
327 | # General
328 | .DS_Store
329 | .AppleDouble
330 | .LSOverride
331 |
332 | # Icon must end with two \r
333 | Icon
334 |
335 | # Thumbnails
336 | ._*
337 |
338 | # Files that might appear in the root of a volume
339 | .DocumentRevisions-V100
340 | .fseventsd
341 | .Spotlight-V100
342 | .TemporaryItems
343 | .Trashes
344 | .VolumeIcon.icns
345 | .com.apple.timemachine.donotpresent
346 |
347 | # Directories potentially created on remote AFP share
348 | .AppleDB
349 | .AppleDesktop
350 | Network Trash Folder
351 | Temporary Items
352 | .apdisk
353 |
354 | ### Windows ###
355 | # Windows thumbnail cache files
356 | Thumbs.db
357 | Thumbs.db:encryptable
358 | ehthumbs.db
359 | ehthumbs_vista.db
360 |
361 | # Dump file
362 | *.stackdump
363 |
364 | # Folder config file
365 | [Dd]esktop.ini
366 |
367 | # Recycle Bin used on file shares
368 | $RECYCLE.BIN/
369 |
370 | # Windows Installer files
371 | *.cab
372 | *.msi
373 | *.msix
374 | *.msm
375 | *.msp
376 |
377 | # Windows shortcuts
378 | *.lnk
379 |
380 | ### Gradle ###
381 | .gradle
382 |
383 | # Ignore Gradle GUI config
384 | gradle-app.setting
385 |
386 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
387 | !gradle-wrapper.jar
388 |
389 | # Cache of project
390 | .gradletasknamecache
391 |
392 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
393 | # gradle/wrapper/gradle-wrapper.properties
394 |
395 | ### Gradle Patch ###
396 | **/build/
397 |
398 | ### AndroidStudio ###
399 | # Covers files to be ignored for android development using Android Studio.
400 |
401 | # Built application files
402 |
403 | # Files for the ART/Dalvik VM
404 |
405 | # Java class files
406 |
407 | # Generated files
408 |
409 | # Gradle files
410 |
411 | # Signing files
412 | .signing/
413 |
414 | # Local configuration file (sdk path, etc)
415 |
416 | # Proguard folder generated by Eclipse
417 |
418 | # Log Files
419 |
420 | # Android Studio
421 | /*/build/
422 | /*/local.properties
423 | /*/out
424 | /*/*/build
425 | /*/*/production
426 | *.swp
427 |
428 | # Android Patch
429 |
430 | # External native build folder generated in Android Studio 2.2 and later
431 |
432 | # NDK
433 | obj/
434 |
435 | # IntelliJ IDEA
436 | /out/
437 |
438 | # User-specific configurations
439 | .idea/caches/
440 | .idea/libraries/
441 | .idea/shelf/
442 | .idea/.name
443 | .idea/compiler.xml
444 | .idea/copyright/profiles_settings.xml
445 | .idea/encodings.xml
446 | .idea/scopes/scope_settings.xml
447 | .idea/vcs.xml
448 | .idea/jsLibraryMappings.xml
449 | .idea/datasources.xml
450 | .idea/dataSources.ids
451 | .idea/sqlDataSources.xml
452 | .idea/dynamic.xml
453 | .idea/uiDesigner.xml
454 |
455 | # OS-specific files
456 | .DS_Store?
457 |
458 | # Legacy Eclipse project files
459 | .classpath
460 | .project
461 | .cproject
462 | .settings/
463 |
464 | # Mobile Tools for Java (J2ME)
465 |
466 | # Package Files #
467 |
468 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
469 |
470 | ## Plugin-specific files:
471 |
472 | # mpeltonen/sbt-idea plugin
473 |
474 | # JIRA plugin
475 |
476 | # Mongo Explorer plugin
477 | .idea/mongoSettings.xml
478 |
479 | # Crashlytics plugin (for Android Studio and IntelliJ)
480 |
481 | ### AndroidStudio Patch ###
482 |
483 | !/gradle/wrapper/gradle-wrapper.jar
484 |
485 | # End of https://www.gitignore.io/api/java,linux,macos,gradle,kotlin,windows,android,jetbrains,jetbrains+iml,jetbrains+all,androidstudio
486 |
487 | *.aar
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 DeweyReed
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | UltimateRingtonePicker
6 |
7 |
8 | Pick ringtone, notification, alarm sound and ringtone files from external storage with an activity or a dialog
9 |
10 |
11 |
25 |
26 |
27 | ## Features
28 |
29 | - Respects Scoped Storage(MediaStore is used)
30 | - Available as an Activity and a Dialog
31 | - Options to pick alarm sound, notification sound, ringtone sound, and external ringtones.
32 | - Ringtone preview
33 | - An interface to set a default entry
34 | - An interface to add custom ringtone entries
35 | - Sorted external ringtones with artists, albums and folders
36 | - Automatically remembers which external ringtones users have picked
37 | - Multi-select
38 | - Dark theme support out of box
39 | - Permissions are handled internally
40 | - Storage Access Framework support
41 |
42 | The library is inspired by [AOSP DeskClock RingtonePickerActivity](https://android.googlesource.com/platform/packages/apps/DeskClock/+/refs/heads/master/src/com/android/deskclock/ringtone/RingtonePickerActivity.kt).
43 |
44 | ## Screenshot
45 |
46 | ||||
47 | |:-:|:-:|:-:|
48 | ||||
49 |
50 | ## Gradle Dependency
51 |
52 | Step 1. Add the JitPack repository to your build file
53 |
54 | Add it in your root build.gradle at the end of repositories:
55 |
56 | ```Groovy
57 | allprojects {
58 | repositories {
59 | maven { url 'https://jitpack.io' }
60 | }
61 | }
62 | ```
63 |
64 | Step 2. Add the dependency
65 |
66 | [](https://jitpack.io/#com.github.DeweyReed/UltimateRingtonePicker)
67 |
68 | ```Groovy
69 | dependencies {
70 | implementation 'com.github.DeweyReed:UltimateRingtonePicker:3.2.0'
71 | }
72 | ```
73 |
74 | ## Usage
75 |
76 | [Demo APK](https://github.com/deweyreed/ultimateringtonepicker/releases) and [examples in the MainActivity](./app/src/main/java/xyz/aprildown/ultimateringtonepicker/app/MainActivity.kt).
77 |
78 | ### 0. Add Permission
79 |
80 | Add ``
81 | or `` when targeting Android
82 | 13 to your Manifest if you are not going to use Storage Access Framework.
83 |
84 | ### 1. Create an `UltimateRingtonePicker.Settings`
85 |
86 | ```Kotlin
87 | val settings = UltimateRingtonePicker.Settings(
88 | systemRingtonePicker = UltimateRingtonePicker.SystemRingtonePicker(
89 | customSection = UltimateRingtonePicker.SystemRingtonePicker.CustomSection(),
90 | defaultSection = UltimateRingtonePicker.SystemRingtonePicker.DefaultSection(),
91 | ringtoneTypes = listOf(
92 | RingtoneManager.TYPE_RINGTONE,
93 | RingtoneManager.TYPE_NOTIFICATION,
94 | RingtoneManager.TYPE_ALARM
95 | )
96 | ),
97 | deviceRingtonePicker = UltimateRingtonePicker.DeviceRingtonePicker(
98 | deviceRingtoneTypes = listOf(
99 | UltimateRingtonePicker.RingtoneCategoryType.All,
100 | UltimateRingtonePicker.RingtoneCategoryType.Artist,
101 | UltimateRingtonePicker.RingtoneCategoryType.Album,
102 | UltimateRingtonePicker.RingtoneCategoryType.Folder
103 | )
104 | )
105 | )
106 | ```
107 |
108 | ### 2. Launch the picker
109 |
110 | - Launch the Activity picker
111 |
112 | 1. Add the Activity to the manifest.
113 |
114 | ``
115 |
116 | 1. Register Activity Result callback
117 |
118 | ```Kotlin
119 | rivate val ringtoneLauncher =
120 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
121 | if (it.resultCode == Activity.RESULT_OK && it.data != null) {
122 | val ringtones = RingtonePickerActivity.getPickerResult(data)
123 | }
124 | }
125 | ```
126 |
127 | 1. Start Activity
128 |
129 | ```Kotlin
130 | ringtoneLauncher.launch(
131 | RingtonePickerActivity.getIntent(
132 | context = this,
133 | settings = settings,
134 | windowTitle = "Activity Picker"
135 | )
136 | )
137 | ```
138 |
139 | - Launch the dialog picker
140 |
141 | 1. Show the dialog
142 |
143 | ```Kotlin
144 | RingtonePickerDialog.createInstance(
145 | settings = settings,
146 | dialogTitle = "Dialog!"
147 | ).show(supportFragmentManager, null)
148 | ```
149 |
150 | 1. Get the result
151 |
152 | Implement `UltimateRingtonePicker.RingtonePickerListener` in your activity or fragment.
153 |
154 | ```Kotlin
155 | override fun onRingtonePicked(ringtones: List) {
156 |
157 | }
158 | ```
159 |
160 | Alternatively, you can launch the dialog and get the result without implementing the interface, but the dialog will be dismissed in `onPause`:
161 |
162 | ```Kotlin
163 | RingtonePickerDialog.createEphemeralInstance(
164 | settings = settings,
165 | dialogTitle = "Dialog",
166 | listener = object : UltimateRingtonePicker.RingtonePickerListener {
167 | override fun onRingtonePicked(ringtones: List) {
168 |
169 | }
170 | }
171 | ).show(supportFragmentManager, null)
172 | ```
173 |
174 | ## BTW
175 |
176 | `UltimateRingtonePicker` supports activity pick `RingtonePickerActivity` and dialog pick `RingtonePickerDialog` out of the box. Both of them are just wrappers of `RingtonePickerFragment`. Therefore, you can directly wrap `RingtonePickerFragment` into your activity/fragment to provide more customization!
177 |
178 | ## License
179 |
180 | [MIT License](./LICENSE)
181 |
--------------------------------------------------------------------------------
/README_OLD.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | UltimateMusicPicker
6 |
7 |
8 | Pick ringtone, notification, alarm sound and music files from external storage with an Activity or a dialog
9 |
10 |
11 |
29 |
30 |
31 | ## Overview
32 |
33 | - Separates music to alarm sound, notification sound, ringtone sound and external music files.
34 | - Provides interface to specify default item
35 | - Provides interface to add custom music items
36 | - Automatically remembers which external music files users picked
37 | - Music Preview
38 | - Available as an Activity and a Dialog
39 | - Dark theme support
40 | - Permission are handled internally
41 | - AndroidX support
42 |
43 | # Table of Contents
44 |
45 | 1. [Sample APK](https://github.com/DeweyReed/UltimateMusicPicker/releases)
46 | 1. [Gradle Dependency](#gradle-dependency)
47 | 1. [Usage](#usage)
48 | 1. [Advanced Usage](#advanced-usage)
49 | 1. [Custom Activity](#custom-activity)
50 | 1. [Dark Theme](#dark-theme)
51 | 1. [Todo List](#todo-list)
52 | 1. [Migrate from 1.X](#migrate-from-1x)
53 | 1. [License](#license)
54 |
55 | ## Gradle Dependency
56 |
57 | Step 1. Add the JitPack repository to your build file
58 |
59 | Add it in your root build.gradle at the end of repositories:
60 |
61 | ```Groovy
62 | allprojects {
63 | repositories {
64 | ...
65 | maven { url 'https://jitpack.io' }
66 | }
67 | }
68 | ```
69 |
70 | Step 2. Add the dependency
71 |
72 | ```Groovy
73 | dependencies {
74 | implementation 'com.github.DeweyReed:UltimateMusicPicker:2.0.6'
75 | }
76 | ```
77 |
78 | ## Usage
79 |
80 | 1. If you wish to pick external music files, add `READ_EXTERNAL_STORAGE` permission to the `Manifest.xml`.
81 |
82 | ``
83 |
84 | 1. If you wish to use picker dialog, implement `MusicPickerListener` in the activity or fragment to get pick result.
85 |
86 | ```Kotlin
87 | interface MusicPickerListener {
88 | fun onMusicPick(uri: Uri, title: String)
89 | fun onPickCanceled()
90 | }
91 | ```
92 |
93 | 1. If you wish to use predefined activity to pick music, add this to the `Manifest.xml`:
94 |
95 | ``
96 |
97 | and receive the pick result in the `onActivityResult`:
98 |
99 | ```Kotlin
100 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
101 | if (resultCode == Activity.RESULT_OK) {
102 | val title = data?.getStringExtra(UltimateMusicPicker.EXTRA_SELECTED_TITLE)
103 | val uri = data?.getParcelableExtra(UltimateMusicPicker.EXTRA_SELECTED_URI)
104 | if (title != null && uri != null) {
105 | onMusicPick(uri, title)
106 | } else {
107 | onPickCanceled()
108 | }
109 | } else super.onActivityResult(requestCode, resultCode, data)
110 | }
111 | ```
112 |
113 | 1. Let's pick some something.
114 |
115 | ```Kotlin
116 | UltimateMusicPicker()
117 | // Picker activity action bar title or dialog title
118 | .windowTitle("UltimateMusicPicker")
119 |
120 | // Add a extra default item
121 | .defaultUri(uri)
122 | // Add a default item and change the default item name("Default" is used otherwise)
123 | .defaultTitleAndUri("My default name", uri)
124 |
125 | // There's a "silent" item by default, use this line to remove it.
126 | .removeSilent()
127 |
128 | // Select this uri
129 | .selectUri(uri)
130 |
131 | // Add some other music items(from R.raw or app's asset)
132 | .additional("Myself Music", uri)
133 | .additional("Another Music", uri)
134 |
135 | // Music preview stream type(AudioManager.STREAM_MUSIC is used by default)
136 | .streamType(AudioManager.STREAM_ALARM)
137 |
138 | // Show different kinds of system ringtones. Calling order determines their display order.
139 | .ringtone()
140 | .notification()
141 | .alarm()
142 | // Show music files from external storage. Requires READ_EXTERNAL_STORAGE permission.
143 | .music()
144 |
145 | // Show a picker dialog
146 | .goWithDialog(supportFragmentManager)
147 | // Or show a picker activity
148 | //.goWithActivity(this, 0, MusicPickerActivity::class.java)
149 | ```
150 |
151 | **When you launch dialog picker in an fragment, remember to use `childFragmentManager` instead of `fragmentManager` to make sure the child can find his/her parents.**
152 |
153 | ## Advanced Usage
154 |
155 | The picker view is a `Fragment` so it can be easily used in an Activity and a dialog.
156 |
157 | ### Custom Activity
158 |
159 | Simply copy and paste [`MusicPickerActivity`](https://github.com/DeweyReed/UltimateMusicPicker/blob/master/library/src/main/java/xyz/aprildown/ringtone/MusicPickerActivity.kt) or [`MusicPickerDialog`](https://github.com/DeweyReed/UltimateMusicPicker/blob/master/library/src/main/java/xyz/aprildown/ringtone/MusicPickerDialog.kt) and create your own. You may notice it's just a wrapper for `MusicPickerFragment` and it can be used in many places(like in a `ViewPager`?)
160 |
161 | What's more, there are two methods in the `UltimateMusicPicker` class to help you.
162 |
163 | ```Kotlin
164 | /**
165 | * Create a setting [Parcelable]. Useful when customize how to start activity
166 | */
167 | fun buildParcelable(): Parcelable
168 |
169 | /**
170 | * Put a setting [Parcelable] into a [Intent]. Useful when customize how to start activity
171 | */
172 | fun putSettingIntoIntent(intent: Intent): Intent
173 | ```
174 |
175 | ### Dark Theme
176 |
177 | This library supports dark theme with a naive way. It works fine when I use `AppCompatDelegate.setDefaultNightMode` to toggle night theme. If this is not enough, open a issue or send a PR.
178 |
179 | ## Todo List
180 |
181 | - Use `READ_CONTENT` to select without permission
182 |
183 | ## Migrate from 1.X
184 |
185 | 2.0.0 renames package name from `xyz.aprildown.ringtone.UltimateMusicPicker` to `xyz.aprildown.ultimatemusicpicker.UltimateMusicPicker` to make it more meaningful.
186 |
187 | So after cleaning up imports, everything should work.
188 |
189 | ## License
190 |
191 | [MIT License](https://github.com/DeweyReed/UltimateMusicPicker/blob/master/LICENSE)
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id('com.android.application')
3 |
4 | id('kotlin-android')
5 | }
6 |
7 | android {
8 | namespace 'xyz.aprildown.ultimateringtonepicker.app'
9 |
10 | compileSdkVersion versions.compile_sdk
11 |
12 | defaultConfig {
13 | applicationId "xyz.aprildown.ultimateringtonepicker.app"
14 | minSdkVersion versions.min_sdk
15 | targetSdkVersion versions.target_sdk
16 | versionCode versions.version_code
17 | versionName versions.version_name
18 | vectorDrawables.useSupportLibrary true
19 | }
20 |
21 | buildTypes {
22 | release {
23 | minifyEnabled true
24 | shrinkResources true
25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
26 | }
27 | }
28 |
29 | compileOptions {
30 | sourceCompatibility = JavaVersion.VERSION_17
31 | targetCompatibility = JavaVersion.VERSION_17
32 | }
33 | kotlinOptions {
34 | jvmTarget = JavaVersion.VERSION_17.toString()
35 | }
36 | }
37 |
38 | dependencies {
39 | implementation project(':library')
40 |
41 | debugImplementation libs.leak_cannary
42 |
43 | implementation libs.androidx_appcompat
44 | implementation libs.material
45 |
46 | implementation libs.easy_permission
47 | }
48 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
9 |
10 |
18 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/assets/asset1.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/assets/asset1.wav
--------------------------------------------------------------------------------
/app/src/main/assets/ringtones/asset2.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/assets/ringtones/asset2.mp3
--------------------------------------------------------------------------------
/app/src/main/java/xyz/aprildown/ultimateringtonepicker/app/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.app
2 |
3 | import android.Manifest
4 | import android.app.Activity
5 | import android.content.Intent
6 | import android.media.RingtoneManager
7 | import android.net.Uri
8 | import android.os.Build
9 | import android.os.Bundle
10 | import android.os.Handler
11 | import android.os.Looper
12 | import android.provider.Settings
13 | import android.view.View
14 | import androidx.activity.result.contract.ActivityResultContracts
15 | import androidx.appcompat.app.AppCompatActivity
16 | import pub.devrel.easypermissions.AfterPermissionGranted
17 | import pub.devrel.easypermissions.EasyPermissions
18 | import pub.devrel.easypermissions.PermissionRequest
19 | import xyz.aprildown.ultimateringtonepicker.RingtonePickerActivity
20 | import xyz.aprildown.ultimateringtonepicker.RingtonePickerDialog
21 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
22 | import java.io.File
23 |
24 | @Suppress("UNUSED_PARAMETER")
25 | class MainActivity : AppCompatActivity(), UltimateRingtonePicker.RingtonePickerListener {
26 |
27 | private var currentSelectedRingtones = listOf()
28 |
29 | private val ringtoneLauncher =
30 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
31 | if (it.resultCode == Activity.RESULT_OK && it.data != null) {
32 | handleResult(RingtonePickerActivity.getPickerResult(it.data))
33 | }
34 | }
35 |
36 | override fun onCreate(savedInstanceState: Bundle?) {
37 | super.onCreate(savedInstanceState)
38 | setContentView(R.layout.activity_main)
39 | }
40 |
41 | fun openPermission(view: View) {
42 | startActivity(
43 | Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
44 | .setData(Uri.parse("package:${packageName}"))
45 | )
46 | }
47 |
48 | fun openStandardActivity(view: View) {
49 | ringtoneLauncher.launch(
50 | RingtonePickerActivity.getIntent(
51 | context = this,
52 | settings = createStandardSettings(),
53 | windowTitle = "Picker Picker"
54 | )
55 | )
56 | }
57 |
58 | fun openStandardDialog(view: View) {
59 | RingtonePickerDialog.createInstance(
60 | createStandardSettings(),
61 | "Dialog!"
62 | ).show(supportFragmentManager, null)
63 | }
64 |
65 | fun emulateSystemDialog(view: View) {
66 | RingtonePickerDialog.createInstance(
67 | UltimateRingtonePicker.Settings(
68 | preSelectUris = currentSelectedRingtones.map { it.uri },
69 | systemRingtonePicker = UltimateRingtonePicker.SystemRingtonePicker(
70 | ringtoneTypes = listOf(
71 | RingtoneManager.TYPE_RINGTONE,
72 | RingtoneManager.TYPE_NOTIFICATION,
73 | RingtoneManager.TYPE_ALARM
74 | )
75 | )
76 | ),
77 | "System Ringtones"
78 | ).show(supportFragmentManager, null)
79 | }
80 |
81 | fun showOnlyDeviceRingtones(view: View) {
82 | val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
83 | Manifest.permission.READ_MEDIA_AUDIO
84 | } else {
85 | Manifest.permission.READ_EXTERNAL_STORAGE
86 | }
87 | if (EasyPermissions.hasPermissions(this, permission)) {
88 | pickDeviceRingtones()
89 | } else {
90 | EasyPermissions.requestPermissions(
91 | PermissionRequest.Builder(this, 0, permission).build()
92 | )
93 | }
94 | }
95 |
96 | override fun onRequestPermissionsResult(
97 | requestCode: Int,
98 | permissions: Array,
99 | grantResults: IntArray
100 | ) {
101 | super.onRequestPermissionsResult(requestCode, permissions, grantResults)
102 | EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this)
103 | }
104 |
105 | @Suppress("unused")
106 | @AfterPermissionGranted(0)
107 | fun onGetPermission() {
108 | pickDeviceRingtones()
109 | }
110 |
111 | private fun pickDeviceRingtones() {
112 | RingtonePickerDialog.createInstance(
113 | UltimateRingtonePicker.Settings(
114 | preSelectUris = currentSelectedRingtones.map { it.uri },
115 | deviceRingtonePicker = UltimateRingtonePicker.DeviceRingtonePicker(
116 | deviceRingtoneTypes = listOf(
117 | UltimateRingtonePicker.RingtoneCategoryType.All,
118 | UltimateRingtonePicker.RingtoneCategoryType.Artist,
119 | UltimateRingtonePicker.RingtoneCategoryType.Album,
120 | UltimateRingtonePicker.RingtoneCategoryType.Folder
121 | )
122 | )
123 | ),
124 | "All Device Ringtones"
125 | ).show(supportFragmentManager, null)
126 | }
127 |
128 | fun useAdditionalRingtones(view: View) {
129 | ringtoneLauncher.launch(
130 | RingtonePickerActivity.getIntent(
131 | context = this,
132 | settings = UltimateRingtonePicker.Settings(
133 | preSelectUris = currentSelectedRingtones.map { it.uri },
134 | systemRingtonePicker = UltimateRingtonePicker.SystemRingtonePicker(
135 | defaultSection = UltimateRingtonePicker.SystemRingtonePicker.DefaultSection(
136 | defaultUri = UltimateRingtonePicker.createRawRingtoneUri(
137 | this,
138 | R.raw.default_ringtone
139 | ),
140 | defaultTitle = "Default Ringtone",
141 | additionalRingtones = listOf(
142 | UltimateRingtonePicker.RingtoneEntry(
143 | UltimateRingtonePicker.createRawRingtoneUri(
144 | this,
145 | R.raw.short_message
146 | ),
147 | "R.raw.short_message"
148 | ),
149 | UltimateRingtonePicker.RingtoneEntry(
150 | UltimateRingtonePicker.createAssetRingtoneUri("asset1.wav"),
151 | "Assets/asset1.mp3"
152 | ),
153 | UltimateRingtonePicker.RingtoneEntry(
154 | UltimateRingtonePicker.createAssetRingtoneUri("ringtones${File.separator}asset2.mp3"),
155 | "Assets/ringtones/asset2.mp3"
156 | )
157 | )
158 | )
159 | )
160 |
161 | ),
162 | windowTitle = "Additional"
163 | )
164 | )
165 | }
166 |
167 | fun enableMultiSelect(view: View) {
168 | ringtoneLauncher.launch(
169 | RingtonePickerActivity.getIntent(
170 | context = this,
171 | settings = UltimateRingtonePicker.Settings(
172 | preSelectUris = currentSelectedRingtones.map { it.uri },
173 | enableMultiSelect = true,
174 | systemRingtonePicker = UltimateRingtonePicker.SystemRingtonePicker(
175 | customSection = UltimateRingtonePicker.SystemRingtonePicker.CustomSection(),
176 | defaultSection = UltimateRingtonePicker.SystemRingtonePicker.DefaultSection(
177 | defaultUri = UltimateRingtonePicker.createRawRingtoneUri(
178 | this,
179 | R.raw.default_ringtone
180 | )
181 | ),
182 | ringtoneTypes = listOf(
183 | RingtoneManager.TYPE_RINGTONE,
184 | RingtoneManager.TYPE_NOTIFICATION,
185 | RingtoneManager.TYPE_ALARM
186 | )
187 | ),
188 | deviceRingtonePicker = UltimateRingtonePicker.DeviceRingtonePicker(
189 | deviceRingtoneTypes = listOf(
190 | UltimateRingtonePicker.RingtoneCategoryType.All,
191 | UltimateRingtonePicker.RingtoneCategoryType.Artist,
192 | UltimateRingtonePicker.RingtoneCategoryType.Album,
193 | UltimateRingtonePicker.RingtoneCategoryType.Folder
194 | )
195 | )
196 | ),
197 | windowTitle = "Multi Select"
198 | )
199 | )
200 | }
201 |
202 | fun safPick(view: View) {
203 | RingtonePickerDialog.createInstance(
204 | UltimateRingtonePicker.Settings(
205 | preSelectUris = currentSelectedRingtones.map { it.uri },
206 | systemRingtonePicker = UltimateRingtonePicker.SystemRingtonePicker(
207 | customSection = UltimateRingtonePicker.SystemRingtonePicker.CustomSection(
208 | useSafSelect = true
209 | ),
210 | ringtoneTypes = listOf(
211 | RingtoneManager.TYPE_RINGTONE,
212 | RingtoneManager.TYPE_NOTIFICATION,
213 | RingtoneManager.TYPE_ALARM
214 | )
215 | )
216 | ),
217 | "All Device Ringtones"
218 | ).show(supportFragmentManager, null)
219 | }
220 |
221 | fun onlySafPick(view: View) {
222 | ringtoneLauncher.launch(
223 | RingtonePickerActivity.getIntent(
224 | context = this,
225 | settings = UltimateRingtonePicker.Settings(
226 | deviceRingtonePicker = UltimateRingtonePicker.DeviceRingtonePicker(
227 | alwaysUseSaf = true
228 | )
229 | ),
230 | windowTitle = "SAF!"
231 | )
232 | )
233 | }
234 |
235 | fun ephemeralDialog(view: View) {
236 | RingtonePickerDialog.createEphemeralInstance(
237 | createStandardSettings(),
238 | "Ephemeral Dialog",
239 | object : UltimateRingtonePicker.RingtonePickerListener {
240 | override fun onRingtonePicked(ringtones: List) {
241 | toast("Ephemeral!")
242 | Handler(Looper.getMainLooper()).postDelayed({
243 | handleResult(ringtones)
244 | }, 1000)
245 | }
246 | }
247 | ).show(supportFragmentManager, null)
248 | }
249 |
250 | private fun createStandardSettings(): UltimateRingtonePicker.Settings =
251 | UltimateRingtonePicker.Settings(
252 | preSelectUris = currentSelectedRingtones.map { it.uri },
253 | loop = false,
254 | systemRingtonePicker = UltimateRingtonePicker.SystemRingtonePicker(
255 | customSection = UltimateRingtonePicker.SystemRingtonePicker.CustomSection(),
256 | defaultSection = UltimateRingtonePicker.SystemRingtonePicker.DefaultSection(
257 | showSilent = true,
258 | defaultUri = UltimateRingtonePicker.createRawRingtoneUri(
259 | this,
260 | R.raw.default_ringtone
261 | ),
262 | defaultTitle = "Default Ringtone",
263 | additionalRingtones = listOf(
264 | UltimateRingtonePicker.RingtoneEntry(
265 | UltimateRingtonePicker.createRawRingtoneUri(this, R.raw.short_message),
266 | "R.raw.short_message"
267 | )
268 | )
269 | ),
270 | ringtoneTypes = listOf(
271 | RingtoneManager.TYPE_RINGTONE,
272 | RingtoneManager.TYPE_NOTIFICATION,
273 | RingtoneManager.TYPE_ALARM
274 | )
275 | ),
276 | deviceRingtonePicker = UltimateRingtonePicker.DeviceRingtonePicker(
277 | deviceRingtoneTypes = listOf(
278 | UltimateRingtonePicker.RingtoneCategoryType.All,
279 | UltimateRingtonePicker.RingtoneCategoryType.Artist,
280 | UltimateRingtonePicker.RingtoneCategoryType.Album,
281 | UltimateRingtonePicker.RingtoneCategoryType.Folder
282 | )
283 | )
284 | )
285 |
286 | override fun onRingtonePicked(ringtones: List) {
287 | handleResult(ringtones)
288 | }
289 |
290 | private fun handleResult(ringtones: List) {
291 | currentSelectedRingtones = ringtones
292 | toast(ringtones.joinToString(separator = "\n") { it.name })
293 | }
294 | }
295 |
--------------------------------------------------------------------------------
/app/src/main/java/xyz/aprildown/ultimateringtonepicker/app/Toasts.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("NOTHING_TO_INLINE", "unused")
2 |
3 | package xyz.aprildown.ultimateringtonepicker.app
4 |
5 | import android.content.Context
6 | import android.widget.Toast
7 | import androidx.fragment.app.Fragment
8 |
9 | // region Short Toast
10 |
11 | inline fun Context.toast(message: Int): Toast = Toast
12 | .makeText(this, message, Toast.LENGTH_SHORT)
13 | .apply {
14 | show()
15 | }
16 |
17 | inline fun Context.toast(message: CharSequence): Toast = Toast
18 | .makeText(this, message, Toast.LENGTH_SHORT)
19 | .apply {
20 | show()
21 | }
22 |
23 | // endregion
24 |
25 | // region Long Toast
26 |
27 | inline fun Context.longToast(message: Int): Toast = Toast
28 | .makeText(this, message, Toast.LENGTH_LONG)
29 | .apply {
30 | show()
31 | }
32 |
33 | inline fun Context.longToast(message: CharSequence): Toast = Toast
34 | .makeText(this, message, Toast.LENGTH_LONG)
35 | .apply {
36 | show()
37 | }
38 |
39 | // endregion
40 |
41 | // region Fragment
42 |
43 | inline fun Fragment.toast(message: Int): Toast = requireContext().toast(message)
44 | inline fun Fragment.longToast(message: Int): Toast = requireContext().longToast(message)
45 |
46 | // endregion
47 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
15 |
16 |
23 |
24 |
30 |
31 |
37 |
38 |
44 |
45 |
51 |
52 |
58 |
59 |
65 |
66 |
72 |
73 |
79 |
80 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/raw/default_ringtone.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/raw/default_ringtone.mp3
--------------------------------------------------------------------------------
/app/src/main/res/raw/short_message.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/app/src/main/res/raw/short_message.mp3
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFB11B
4 | #FF9800
5 | #9C27B0
6 |
7 | @color/colorPrimary
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | UltimateMusicPicker
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/art/activity.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/art/activity.webp
--------------------------------------------------------------------------------
/art/dark.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/art/dark.webp
--------------------------------------------------------------------------------
/art/dialog.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/art/dialog.webp
--------------------------------------------------------------------------------
/art/ic_launcher-web.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/art/ic_launcher-web.webp
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | apply from: 'dependencies.gradle'
5 | repositories {
6 | google()
7 | gradlePluginPortal()
8 | }
9 | dependencies {
10 | classpath "com.android.tools.build:gradle:${versions.agp}"
11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
12 |
13 | classpath "com.github.ben-manes:gradle-versions-plugin:${versions.versions}"
14 | }
15 | }
16 |
17 | apply plugin: "com.github.ben-manes.versions"
18 |
19 | allprojects {
20 | repositories {
21 | google()
22 | mavenCentral()
23 | }
24 | }
25 |
26 | task clean(type: Delete) {
27 | delete rootProject.buildDir
28 | }
29 |
30 | def isNonStable = { String version ->
31 | def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) }
32 | def regex = /^[0-9,.v-]+(-r)?$/
33 | return !stableKeyword && !(version ==~ regex)
34 | }
35 |
36 | dependencyUpdates {
37 | rejectVersionIf {
38 | (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion))
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/dependencies.gradle:
--------------------------------------------------------------------------------
1 | ext {
2 | versions = [
3 | agp : '8.1.4',
4 | kotlin : '1.9.22',
5 | versions : '0.50.0',
6 |
7 | compile_sdk : 34,
8 | min_sdk : 21,
9 | target_sdk : 34,
10 |
11 | version_code: 330,
12 | version_name: '3.3.0',
13 | ]
14 | libs = [
15 | // Kotlin Coroutines
16 | kotlin_coroutines_core : 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3',
17 | kotlin_coroutines_android : 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3',
18 |
19 | androidx_appcompat : 'androidx.appcompat:appcompat:1.6.1',
20 | androidx_core : 'androidx.core:core-ktx:1.12.0',
21 | androidx_recyclerview : 'androidx.recyclerview:recyclerview:1.3.0',
22 |
23 | material : 'com.google.android.material:material:1.4.0',
24 |
25 | lifecycle_livedata : 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0',
26 | lifecycle_viewmodel : 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0',
27 |
28 | // Navigation
29 | androidx_navigation_fragment: 'androidx.navigation:navigation-fragment-ktx:2.7.6',
30 | androidx_navigation_ui : 'androidx.navigation:navigation-ui-ktx:2.7.6',
31 |
32 | leak_cannary : 'com.squareup.leakcanary:leakcanary-android:2.13',
33 |
34 | fastadapter : 'com.mikepenz:fastadapter:5.7.0',
35 | fastadapter_binding : 'com.mikepenz:fastadapter-extensions-binding:5.7.0',
36 |
37 | easy_permission : 'pub.devrel:easypermissions:3.0.0',
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DeweyReed/UltimateRingtonePicker/ed873ed1cbdcc76c7bcac63b1f6323cecc888e91/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.2.1-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 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
87 |
88 | # Use the maximum available, or set MAX_FD != -1 to use that value.
89 | MAX_FD=maximum
90 |
91 | warn () {
92 | echo "$*"
93 | } >&2
94 |
95 | die () {
96 | echo
97 | echo "$*"
98 | echo
99 | exit 1
100 | } >&2
101 |
102 | # OS specific support (must be 'true' or 'false').
103 | cygwin=false
104 | msys=false
105 | darwin=false
106 | nonstop=false
107 | case "$( uname )" in #(
108 | CYGWIN* ) cygwin=true ;; #(
109 | Darwin* ) darwin=true ;; #(
110 | MSYS* | MINGW* ) msys=true ;; #(
111 | NONSTOP* ) nonstop=true ;;
112 | esac
113 |
114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
115 |
116 |
117 | # Determine the Java command to use to start the JVM.
118 | if [ -n "$JAVA_HOME" ] ; then
119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
120 | # IBM's JDK on AIX uses strange locations for the executables
121 | JAVACMD=$JAVA_HOME/jre/sh/java
122 | else
123 | JAVACMD=$JAVA_HOME/bin/java
124 | fi
125 | if [ ! -x "$JAVACMD" ] ; then
126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
127 |
128 | Please set the JAVA_HOME variable in your environment to match the
129 | location of your Java installation."
130 | fi
131 | else
132 | JAVACMD=java
133 | if ! command -v java >/dev/null 2>&1
134 | then
135 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
136 |
137 | Please set the JAVA_HOME variable in your environment to match the
138 | location of your Java installation."
139 | fi
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
147 | # shellcheck disable=SC3045
148 | MAX_FD=$( ulimit -H -n ) ||
149 | warn "Could not query maximum file descriptor limit"
150 | esac
151 | case $MAX_FD in #(
152 | '' | soft) :;; #(
153 | *)
154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
155 | # shellcheck disable=SC3045
156 | ulimit -n "$MAX_FD" ||
157 | warn "Could not set maximum file descriptor limit to $MAX_FD"
158 | esac
159 | fi
160 |
161 | # Collect all arguments for the java command, stacking in reverse order:
162 | # * args from the command line
163 | # * the main class name
164 | # * -classpath
165 | # * -D...appname settings
166 | # * --module-path (only if needed)
167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
168 |
169 | # For Cygwin or MSYS, switch paths to Windows format before running java
170 | if "$cygwin" || "$msys" ; then
171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
173 |
174 | JAVACMD=$( cygpath --unix "$JAVACMD" )
175 |
176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
177 | for arg do
178 | if
179 | case $arg in #(
180 | -*) false ;; # don't mess with options #(
181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
182 | [ -e "$t" ] ;; #(
183 | *) false ;;
184 | esac
185 | then
186 | arg=$( cygpath --path --ignore --mixed "$arg" )
187 | fi
188 | # Roll the args list around exactly as many times as the number of
189 | # args, so each arg winds up back in the position where it started, but
190 | # possibly modified.
191 | #
192 | # NB: a `for` loop captures its iteration list before it begins, so
193 | # changing the positional parameters here affects neither the number of
194 | # iterations, nor the values presented in `arg`.
195 | shift # remove old arg
196 | set -- "$@" "$arg" # push replacement arg
197 | done
198 | fi
199 |
200 |
201 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
202 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
203 |
204 | # Collect all arguments for the java command;
205 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
206 | # shell script including quotes and variable substitutions, so put them in
207 | # double quotes to make sure that they get re-expanded; and
208 | # * put everything else in single quotes, so that it's not re-expanded.
209 |
210 | set -- \
211 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
212 | -classpath "$CLASSPATH" \
213 | org.gradle.wrapper.GradleWrapperMain \
214 | "$@"
215 |
216 | # Stop when "xargs" is not available.
217 | if ! command -v xargs >/dev/null 2>&1
218 | then
219 | die "xargs is not available"
220 | fi
221 |
222 | # Use "xargs" to parse quoted args.
223 | #
224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
225 | #
226 | # In Bash we could simply go:
227 | #
228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
229 | # set -- "${ARGS[@]}" "$@"
230 | #
231 | # but POSIX shell has neither arrays nor command substitution, so instead we
232 | # post-process each arg (as a line of input to sed) to backslash-escape any
233 | # character that might be a shell metacharacter, then use eval to reverse
234 | # that process (while maintaining the separation between arguments), and wrap
235 | # the whole thing up as a single "set" statement.
236 | #
237 | # This will of course break if any of these variables contains a newline or
238 | # an unmatched quote.
239 | #
240 |
241 | eval "set -- $(
242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
243 | xargs -n1 |
244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
245 | tr '\n' ' '
246 | )" '"$@"'
247 |
248 | exec "$JAVACMD" "$@"
249 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/library/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/library/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id('com.android.library')
3 |
4 | id('kotlin-android')
5 | id('kotlin-parcelize')
6 | id('maven-publish')
7 | }
8 |
9 | android {
10 | namespace 'xyz.aprildown.ultimateringtonepicker'
11 |
12 | compileOptions {
13 | kotlinOptions.freeCompilerArgs += ['-module-name', "xyz.aprildown.ultimateringtonepicker.library"]
14 | }
15 |
16 | compileSdkVersion versions.compile_sdk
17 |
18 | defaultConfig {
19 | minSdkVersion versions.min_sdk
20 | targetSdkVersion versions.target_sdk
21 | vectorDrawables.useSupportLibrary true
22 | }
23 |
24 | compileOptions {
25 | sourceCompatibility = JavaVersion.VERSION_17
26 | targetCompatibility = JavaVersion.VERSION_17
27 | }
28 | kotlinOptions {
29 | jvmTarget = JavaVersion.VERSION_17.toString()
30 | }
31 |
32 | resourcePrefix 'urp_'
33 | buildFeatures {
34 | viewBinding true
35 | buildConfig false
36 | }
37 |
38 | publishing {
39 | singleVariant('release') {
40 | withSourcesJar()
41 | }
42 | }
43 | }
44 |
45 | dependencies {
46 | implementation libs.kotlin_coroutines_core
47 | implementation libs.kotlin_coroutines_android
48 |
49 | implementation libs.androidx_appcompat
50 | implementation libs.androidx_core
51 | implementation libs.androidx_recyclerview
52 |
53 | implementation libs.material
54 |
55 | implementation libs.lifecycle_livedata
56 | implementation libs.lifecycle_viewmodel
57 |
58 | implementation libs.androidx_navigation_fragment
59 | implementation libs.androidx_navigation_ui
60 |
61 | implementation libs.fastadapter
62 | implementation libs.fastadapter_binding
63 |
64 | implementation libs.easy_permission
65 | }
66 |
67 | publishing {
68 | publications {
69 | release(MavenPublication) {
70 | groupId = 'com.github.DeweyReed'
71 | artifactId = 'UltimateRingtonePicker'
72 | version = versions.version_name
73 |
74 | afterEvaluate {
75 | from components.release
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/library/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/RingtonePickerActivity.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Bundle
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.core.content.IntentCompat
9 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpActivityRingtonePickerBinding
10 |
11 | /**
12 | * Created on 2018/6/7.
13 | */
14 |
15 | class RingtonePickerActivity : AppCompatActivity(), UltimateRingtonePicker.RingtonePickerListener {
16 |
17 | override fun onCreate(savedInstanceState: Bundle?) {
18 | super.onCreate(savedInstanceState)
19 | val binding = UrpActivityRingtonePickerBinding.inflate(layoutInflater)
20 | setContentView(binding.root)
21 | supportActionBar?.run {
22 | setDisplayHomeAsUpEnabled(true)
23 | title = intent.getStringExtra(EXTRA_TITLE)
24 | }
25 |
26 | if (savedInstanceState == null) {
27 | val settings = IntentCompat.getParcelableExtra(
28 | intent,
29 | EXTRA_SETTINGS,
30 | UltimateRingtonePicker.Settings::class.java
31 | )
32 | if (settings == null) {
33 | finish()
34 | return
35 | }
36 | val fragment = settings.createFragment()
37 | supportFragmentManager.beginTransaction()
38 | .replace(R.id.layoutRingtonePicker, fragment, TAG_RINGTONE_PICKER)
39 | .setPrimaryNavigationFragment(fragment)
40 | .commit()
41 | }
42 |
43 | binding.btnSelect.setOnClickListener {
44 | getRingtonePickerFragment().onSelectClick()
45 | }
46 | binding.btnCancel.setOnClickListener {
47 | onBackPressedDispatcher.onBackPressed()
48 | }
49 | }
50 |
51 | override fun onSupportNavigateUp(): Boolean {
52 | onBackPressedDispatcher.onBackPressed()
53 | return true
54 | }
55 |
56 | override fun onRingtonePicked(ringtones: List) {
57 | setResult(
58 | Activity.RESULT_OK,
59 | Intent().putExtra(EXTRA_RESULT, ringtones.toTypedArray())
60 | )
61 | finish()
62 | }
63 |
64 | private fun getRingtonePickerFragment(): RingtonePickerFragment {
65 | return supportFragmentManager.findFragmentByTag(TAG_RINGTONE_PICKER) as RingtonePickerFragment
66 | }
67 |
68 | companion object {
69 |
70 | private const val EXTRA_TITLE = "title"
71 | private const val EXTRA_RESULT = "result"
72 |
73 | @JvmStatic
74 | fun getIntent(
75 | context: Context,
76 | settings: UltimateRingtonePicker.Settings,
77 | windowTitle: CharSequence
78 | ): Intent = Intent(context, RingtonePickerActivity::class.java).apply {
79 | putExtra(EXTRA_SETTINGS, settings)
80 | putExtra(EXTRA_TITLE, windowTitle)
81 | }
82 |
83 | @JvmStatic
84 | fun getPickerResult(intent: Intent?): List {
85 | if (intent == null) return emptyList()
86 | return IntentCompat.getParcelableArrayExtra(
87 | intent,
88 | EXTRA_RESULT,
89 | UltimateRingtonePicker.RingtoneEntry::class.java
90 | )?.filterIsInstance() ?: emptyList()
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/RingtonePickerDialog.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker
2 |
3 | import android.app.Dialog
4 | import android.os.Bundle
5 | import android.view.KeyEvent
6 | import android.view.LayoutInflater
7 | import android.view.View
8 | import android.view.ViewGroup
9 | import androidx.appcompat.app.AlertDialog
10 | import androidx.core.os.BundleCompat
11 | import androidx.fragment.app.DialogFragment
12 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
13 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpDialogBinding
14 |
15 | class RingtonePickerDialog : DialogFragment(), UltimateRingtonePicker.RingtonePickerListener {
16 |
17 | private var directListener: UltimateRingtonePicker.RingtonePickerListener? = null
18 | private lateinit var binding: UrpDialogBinding
19 |
20 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
21 | binding = UrpDialogBinding.inflate(layoutInflater)
22 | val builder = MaterialAlertDialogBuilder(requireContext())
23 |
24 | val title = arguments?.getCharSequence(EXTRA_TITLE)
25 |
26 | builder.apply {
27 | setView(binding.root)
28 | if (!title.isNullOrBlank()) {
29 | setTitle(title)
30 | }
31 | setNegativeButton(android.R.string.cancel, null)
32 | setPositiveButton(android.R.string.ok, null)
33 | }
34 | val dialog = builder.create()
35 | dialog.setOnShowListener {
36 | dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
37 | handleBack()
38 | }
39 | dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
40 | getRingtonePickerFragment().onSelectClick()
41 | }
42 | }
43 | dialog.setOnKeyListener { _, keyCode, keyEvent ->
44 | if (keyCode == KeyEvent.KEYCODE_BACK && keyEvent.action == KeyEvent.ACTION_UP) {
45 | handleBack()
46 | true
47 | } else {
48 | false
49 | }
50 | }
51 | return dialog
52 | }
53 |
54 | override fun onCreateView(
55 | inflater: LayoutInflater,
56 | container: ViewGroup?,
57 | savedInstanceState: Bundle?
58 | ): View = binding.root
59 |
60 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
61 | val arguments = requireArguments()
62 | if (arguments.getBoolean(EXTRA_EPHEMERAL) && directListener == null) {
63 | dismiss()
64 | }
65 |
66 | if (savedInstanceState == null) {
67 | val settings = BundleCompat.getParcelable(
68 | arguments,
69 | EXTRA_SETTINGS,
70 | UltimateRingtonePicker.Settings::class.java
71 | )
72 | if (settings == null) {
73 | dismiss()
74 | return
75 | }
76 | val fragment = settings.createFragment()
77 | childFragmentManager.beginTransaction()
78 | .add(R.id.urpFrameDialog, fragment, TAG_RINGTONE_PICKER)
79 | .setPrimaryNavigationFragment(fragment)
80 | .commit()
81 | }
82 | }
83 |
84 | override fun onRingtonePicked(ringtones: List) {
85 | (directListener ?: requireRingtonePickerListener()).onRingtonePicked(ringtones)
86 | dismiss()
87 | }
88 |
89 | private fun handleBack() {
90 | if (!getRingtonePickerFragment().onBackClick()) {
91 | dismiss()
92 | }
93 | }
94 |
95 | private fun getRingtonePickerFragment(): RingtonePickerFragment {
96 | return childFragmentManager.findFragmentByTag(TAG_RINGTONE_PICKER) as RingtonePickerFragment
97 | }
98 |
99 | companion object {
100 | private const val EXTRA_TITLE = "title"
101 | private const val EXTRA_EPHEMERAL = "ephemeral"
102 |
103 | @JvmStatic
104 | fun createInstance(
105 | settings: UltimateRingtonePicker.Settings,
106 | dialogTitle: CharSequence?
107 | ): RingtonePickerDialog = RingtonePickerDialog().apply {
108 | arguments = Bundle().apply {
109 | putParcelable(EXTRA_SETTINGS, settings)
110 | putCharSequence(EXTRA_TITLE, dialogTitle)
111 | }
112 | }
113 |
114 | /**
115 | * The dialog will be dismissed in onPause but give you the result directly in the [listener].
116 | */
117 | @JvmStatic
118 | fun createEphemeralInstance(
119 | settings: UltimateRingtonePicker.Settings,
120 | dialogTitle: CharSequence?,
121 | listener: UltimateRingtonePicker.RingtonePickerListener
122 | ): RingtonePickerDialog = RingtonePickerDialog().apply {
123 | arguments = Bundle().apply {
124 | putParcelable(EXTRA_SETTINGS, settings)
125 | putCharSequence(EXTRA_TITLE, dialogTitle)
126 | putBoolean(EXTRA_EPHEMERAL, true)
127 | }
128 | directListener = listener
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/RingtonePickerFragment.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker
2 |
3 | import android.content.Context
4 | import android.os.Bundle
5 | import android.view.View
6 | import androidx.core.os.BundleCompat
7 | import androidx.fragment.app.Fragment
8 | import androidx.lifecycle.ViewModel
9 | import androidx.lifecycle.ViewModelProvider
10 | import androidx.lifecycle.viewmodel.CreationExtras
11 | import androidx.navigation.fragment.NavHostFragment
12 | import androidx.navigation.navGraphViewModels
13 | import xyz.aprildown.ultimateringtonepicker.ui.EventHandler
14 |
15 | /**
16 | * Structure:
17 | * - [RingtonePickerFragment]
18 | * - [SystemRingtoneFragment]
19 | * - [DeviceRingtoneFragment]
20 | * - [RingtoneFragment]
21 | * - [CategoryFragment]
22 | * - [RingtoneFragment]
23 | */
24 | class RingtonePickerFragment : NavHostFragment() {
25 |
26 | private lateinit var pickListener: UltimateRingtonePicker.RingtonePickerListener
27 |
28 | override fun onAttach(context: Context) {
29 | super.onAttach(context)
30 | pickListener = requireRingtonePickerListener()
31 | }
32 |
33 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
34 | val settings = BundleCompat.getParcelable(
35 | arguments ?: Bundle.EMPTY,
36 | EXTRA_SETTINGS,
37 | UltimateRingtonePicker.Settings::class.java
38 | ) ?: UltimateRingtonePicker.Settings()
39 |
40 | navController.graph = navController.navInflater.inflate(R.navigation.urp_nav_graph).apply {
41 | setStartDestination(
42 | if (settings.systemRingtonePicker == null) {
43 | R.id.urp_dest_device
44 | } else {
45 | R.id.urp_dest_system
46 | }
47 | )
48 | }
49 |
50 | val viewModel by navGraphViewModels(R.id.urp_nav_graph) {
51 | object : ViewModelProvider.Factory {
52 | @Suppress("UNCHECKED_CAST")
53 | override fun create(
54 | modelClass: Class,
55 | extras: CreationExtras
56 | ): T {
57 | return when (modelClass) {
58 | RingtonePickerViewModel::class.java ->
59 | RingtonePickerViewModel(requireActivity().application, settings) as T
60 | else -> throw IllegalArgumentException()
61 | }
62 | }
63 | }
64 | }
65 |
66 | viewModel.finalSelection.observe(viewLifecycleOwner) { ringtones ->
67 | if (ringtones != null) {
68 | pickListener.onRingtonePicked(
69 | ringtones.filter { it.isValid }
70 | .map {
71 | UltimateRingtonePicker.RingtoneEntry(uri = it.uri, name = it.title)
72 | }
73 | )
74 | }
75 | }
76 | }
77 |
78 | private fun getTopFragment(): Fragment? {
79 | return childFragmentManager.primaryNavigationFragment
80 | }
81 |
82 | fun onSelectClick() {
83 | (getTopFragment() as? EventHandler)?.onSelect()
84 | }
85 |
86 | /**
87 | * @return If the back event is consumed.
88 | * If it isn't consumed(false), you can finish the activity or the dialog.
89 | */
90 | fun onBackClick(): Boolean {
91 | return (getTopFragment() as? EventHandler)?.onBack() == true
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/RingtonePickerViewModel.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker
2 |
3 | import android.app.Application
4 | import android.content.ContentResolver
5 | import android.content.Intent
6 | import android.net.Uri
7 | import android.provider.MediaStore
8 | import android.provider.OpenableColumns
9 | import androidx.collection.ArrayMap
10 | import androidx.lifecycle.AndroidViewModel
11 | import androidx.lifecycle.LiveData
12 | import androidx.lifecycle.MutableLiveData
13 | import androidx.lifecycle.map
14 | import androidx.lifecycle.viewModelScope
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.launch
17 | import kotlinx.coroutines.withContext
18 | import xyz.aprildown.ultimateringtonepicker.data.Category
19 | import xyz.aprildown.ultimateringtonepicker.data.CustomRingtoneModel
20 | import xyz.aprildown.ultimateringtonepicker.data.DeviceRingtoneModel
21 | import xyz.aprildown.ultimateringtonepicker.data.Ringtone
22 | import xyz.aprildown.ultimateringtonepicker.data.SystemRingtoneModel
23 | import xyz.aprildown.ultimateringtonepicker.music.AsyncRingtonePlayer
24 |
25 | internal class RingtonePickerViewModel(
26 | application: Application,
27 | val settings: UltimateRingtonePicker.Settings
28 | ) : AndroidViewModel(application) {
29 |
30 | private val mediaPlayer by lazy { AsyncRingtonePlayer(application) }
31 |
32 | val currentSelectedUris = mutableSetOf()
33 |
34 | private val customRingtoneModel by lazy { CustomRingtoneModel(application) }
35 | val customRingtones = mutableSetOf()
36 |
37 | private val systemRingtoneModel by lazy { SystemRingtoneModel(application) }
38 | val systemRingtones = ArrayMap>()
39 |
40 | /**
41 | * Use this event to get [customRingtones] and [systemRingtones].
42 | */
43 | private val _systemRingtoneLoadedEvent = MutableLiveData()
44 | val systemRingtoneLoadedEvent: LiveData = _systemRingtoneLoadedEvent
45 | private var firstLoad: Boolean = true
46 |
47 | val finalSelection = MutableLiveData>()
48 |
49 | private val deviceRingtoneModel by lazy { DeviceRingtoneModel(application) }
50 |
51 | /**
52 | * All device ringtones. Artist and Album ringtones are filtered from this.
53 | */
54 | private val deviceRingtones by lazy {
55 | val result = MutableLiveData>()
56 | viewModelScope.launch(Dispatchers.IO) {
57 | result.postValue(deviceRingtoneModel.getAllDeviceRingtones())
58 | }
59 | result
60 | }
61 | val allDeviceRingtones: LiveData> get() = deviceRingtones
62 |
63 | private val categories by lazy {
64 | ArrayMap>>().also { map ->
65 | arrayOf(
66 | UltimateRingtonePicker.RingtoneCategoryType.Artist,
67 | UltimateRingtonePicker.RingtoneCategoryType.Album,
68 | UltimateRingtonePicker.RingtoneCategoryType.Folder
69 | ).forEach { categoryType ->
70 | map[categoryType] = MutableLiveData()
71 | }
72 | viewModelScope.launch(Dispatchers.IO) {
73 | repeat(map.size) { index ->
74 | val categoryType = map.keyAt(index)
75 | val liveData = map.valueAt(index)
76 | liveData.postValue(deviceRingtoneModel.getCategories(categoryType))
77 | }
78 | }
79 | }
80 | }
81 |
82 | /**
83 | * Since folder ringtones have a different load mechanism, we use another map to host them.
84 | * Key: Folder id
85 | */
86 | private val folderRingtones = ArrayMap>>()
87 |
88 | init {
89 | viewModelScope.launch {
90 |
91 | settings.preSelectUris.let { preSelect ->
92 | currentSelectedUris += if (!settings.enableMultiSelect && preSelect.size > 1) {
93 | preSelect.take(1)
94 | } else {
95 | preSelect
96 | }
97 | }
98 |
99 | val systemRingtonePicker =
100 | settings.systemRingtonePicker
101 | val customSection =
102 | systemRingtonePicker?.customSection
103 |
104 | if (customSection != null) {
105 | withContext(Dispatchers.IO) {
106 | customRingtones.addAll(customRingtoneModel.getCustomRingtones())
107 | }
108 | }
109 |
110 | val ringtoneTypes = systemRingtonePicker?.ringtoneTypes
111 | if (ringtoneTypes?.isNotEmpty() == true) {
112 | withContext(Dispatchers.IO) {
113 | systemRingtoneModel.preloadRingtoneTitles(ringtoneTypes)
114 | ringtoneTypes.forEach { ringtoneType ->
115 | systemRingtones[ringtoneType] =
116 | systemRingtoneModel.getRingtones(ringtoneType).map {
117 | Ringtone(it, systemRingtoneModel.getRingtoneTitle(it))
118 | }
119 | }
120 | }
121 | }
122 |
123 | _systemRingtoneLoadedEvent.value = Unit
124 | }
125 | }
126 |
127 | val currentPlayingUri: Uri? get() = mediaPlayer.currentPlayingUri
128 |
129 | fun startPlaying(uri: Uri) {
130 | mediaPlayer.play(uri, settings.loop, settings.streamType)
131 | }
132 |
133 | fun stopPlaying() {
134 | mediaPlayer.stop()
135 | }
136 |
137 | fun deleteCustomRingtone(uri: Uri) {
138 | customRingtoneModel.removeCustomRingtone(uri)
139 |
140 | customRingtones.clear()
141 | customRingtones.addAll(customRingtoneModel.getCustomRingtones())
142 | }
143 |
144 | fun onDeviceSelection(selectedRingtones: List) {
145 | if (!settings.enableMultiSelect && selectedRingtones.isNotEmpty()) {
146 | require(selectedRingtones.size == 1)
147 | currentSelectedUris.clear()
148 | }
149 | currentSelectedUris.addAll(selectedRingtones.map { it.uri })
150 |
151 | selectedRingtones.forEach {
152 | customRingtoneModel.addCustomRingtone(it.uri, it.title)
153 | }
154 | // In this way we can keep ringtone order. They're cached anyway.
155 | customRingtones.clear()
156 | customRingtones.addAll(customRingtoneModel.getCustomRingtones())
157 |
158 | _systemRingtoneLoadedEvent.value = Unit
159 | }
160 |
161 | fun consumeFirstLoad(): Boolean {
162 | val result = firstLoad
163 | firstLoad = false
164 | return result
165 | }
166 |
167 | fun onSafSelect(contentResolver: ContentResolver, data: Intent): Ringtone? {
168 | val uri = data.data
169 | if (uri == null || uri == RINGTONE_URI_SILENT) return null
170 | // Bail if the permission to read (playback) the audio at the uri was not granted.
171 | if (data.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION
172 | != Intent.FLAG_GRANT_READ_URI_PERMISSION
173 | ) {
174 | return null
175 | }
176 |
177 | // Take the long-term permission to read (playback) the audio at the uri.
178 | contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
179 |
180 | try {
181 | contentResolver.query(uri, null, null, null, null)?.use { cursor ->
182 |
183 | if (!cursor.moveToFirst()) return@use
184 |
185 | var title: String? = null
186 |
187 | // If the file was a media file, return its title.
188 | val titleIndex = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE)
189 | if (titleIndex != -1) {
190 | title = cursor.getString(titleIndex)
191 | } else {
192 | // If the file was a simple openable, return its display name.
193 | val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
194 | if (displayNameIndex != -1) {
195 | var displayName = cursor.getString(displayNameIndex)
196 | val dotIndex = displayName.lastIndexOf(".")
197 | if (dotIndex > 0) {
198 | displayName = displayName.substring(0, dotIndex)
199 | }
200 | title = displayName
201 | }
202 | }
203 |
204 | if (title != null) {
205 | return Ringtone(uri, title)
206 | }
207 | }
208 | } catch (e: Exception) {
209 | e.printStackTrace()
210 | }
211 | return null
212 | }
213 |
214 | fun onFinalSelection(selectedRingtones: List) {
215 | finalSelection.value = selectedRingtones
216 | }
217 |
218 | fun getRingtoneLiveData(
219 | categoryType: UltimateRingtonePicker.RingtoneCategoryType,
220 | categoryId: Long
221 | ): LiveData> {
222 | return if (categoryType == UltimateRingtonePicker.RingtoneCategoryType.Folder) {
223 | folderRingtones[categoryId] ?: MutableLiveData>().also {
224 | folderRingtones[categoryId] = it
225 | viewModelScope.launch(Dispatchers.IO) {
226 | it.postValue(deviceRingtoneModel.getFolderRingtones(categoryId))
227 | }
228 | }
229 | } else {
230 | deviceRingtones.map { allRingtones ->
231 | when (categoryType) {
232 | UltimateRingtonePicker.RingtoneCategoryType.All -> allRingtones
233 | UltimateRingtonePicker.RingtoneCategoryType.Artist ->
234 | allRingtones.filter { it.artistId == categoryId }
235 | UltimateRingtonePicker.RingtoneCategoryType.Album ->
236 | allRingtones.filter { it.albumId == categoryId }
237 | else -> throw IllegalStateException()
238 | }
239 | }
240 | }
241 | }
242 |
243 | fun getCategoryLiveData(categoryType: UltimateRingtonePicker.RingtoneCategoryType): LiveData>? {
244 | return categories[categoryType]
245 | }
246 |
247 | override fun onCleared() {
248 | super.onCleared()
249 | stopPlaying()
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/UltimateRingtonePicker.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker
2 |
3 | import android.content.ContentResolver
4 | import android.content.Context
5 | import android.media.AudioManager
6 | import android.media.RingtoneManager
7 | import android.net.Uri
8 | import android.os.Bundle
9 | import android.os.Parcelable
10 | import androidx.annotation.AnyRes
11 | import kotlinx.parcelize.Parcelize
12 |
13 | class UltimateRingtonePicker {
14 |
15 | @Parcelize
16 | data class RingtoneEntry(
17 | val uri: Uri,
18 | val name: String
19 | ) : Parcelable
20 |
21 | interface RingtonePickerListener {
22 | /**
23 | * @param ringtones It may be empty or contain one or more entries.
24 | * You should also check Uri.EMPTY if user can select the silent ringtone.
25 | */
26 | fun onRingtonePicked(ringtones: List)
27 | }
28 |
29 | @Parcelize
30 | data class SystemRingtonePicker(
31 | val customSection: CustomSection? = null,
32 | val defaultSection: DefaultSection? = null,
33 | /**
34 | * Values from [RingtoneManager.TYPE_RINGTONE], [RingtoneManager.TYPE_NOTIFICATION] and
35 | * [RingtoneManager.TYPE_ALARM].
36 | */
37 | val ringtoneTypes: List = emptyList()
38 | ) : Parcelable {
39 |
40 | @Parcelize
41 | data class CustomSection(
42 | /**
43 | * By default, the library will ask for READ_EXTERNAL_STORAGE permission and show external
44 | * ringtones, set this to true to use Storage Access Framework which doesn't require
45 | * the permission.
46 | */
47 | val useSafSelect: Boolean = false,
48 |
49 | /**
50 | * Only used when [useSafSelect] is false.
51 | */
52 | val launchSafOnPermissionDenied: Boolean = true,
53 | val launchSafOnPermissionPermanentlyDenied: Boolean = true
54 | ) : Parcelable
55 |
56 | @Parcelize
57 | data class DefaultSection(
58 | /**
59 | * An extra silent ringtone entry.
60 | */
61 | val showSilent: Boolean = true,
62 |
63 | /**
64 | * An extra default ringtone entry.
65 | */
66 | val defaultUri: Uri? = null,
67 | val defaultTitle: String? = null,
68 |
69 | /**
70 | * Some other ringtone entries.
71 | *
72 | * Use [createAssetRingtoneUri] to create Asset ringtone URIs.
73 | * Use [createRawRingtoneUri] to create R.raw.XXX URIs
74 | */
75 | val additionalRingtones: List = emptyList()
76 | ) : Parcelable
77 | }
78 |
79 | /**
80 | * Used in [DeviceRingtonePicker]
81 | */
82 | enum class RingtoneCategoryType {
83 | All, Artist, Album, Folder
84 | }
85 |
86 | @Parcelize
87 | data class DeviceRingtonePicker(
88 | val deviceRingtoneTypes: List = emptyList(),
89 | val alwaysUseSaf: Boolean = false
90 | ) : Parcelable
91 |
92 | @Parcelize
93 | data class Settings(
94 | val preSelectUris: List = emptyList(),
95 | val enableMultiSelect: Boolean = false,
96 | val loop: Boolean = true,
97 |
98 | /**
99 | * Ringtone preview stream type.
100 | */
101 | val streamType: Int = AudioManager.STREAM_MUSIC,
102 |
103 | val systemRingtonePicker: SystemRingtonePicker? = null,
104 |
105 | /**
106 | * If [systemRingtonePicker] == null && [deviceRingtonePicker] != null, you need to
107 | * handle READ_EXTERNAL_STORAGE permission before showing the picker.
108 | */
109 | val deviceRingtonePicker: DeviceRingtonePicker? = null
110 | ) : Parcelable {
111 |
112 | init {
113 | require(!(systemRingtonePicker == null && deviceRingtonePicker == null))
114 | }
115 |
116 | fun createFragment(): RingtonePickerFragment = RingtonePickerFragment().apply {
117 | arguments = Bundle().apply {
118 | putParcelable(EXTRA_SETTINGS, this@Settings)
119 | }
120 | }
121 | }
122 |
123 | companion object {
124 | /**
125 | * Help you build a asset URI.
126 | */
127 | @JvmStatic
128 | fun createAssetRingtoneUri(fileName: String): Uri = Uri.parse("$ASSET_URI_PREFIX$fileName")
129 |
130 | /**
131 | * Help you build a R.raw.* URI.
132 | * @param resourceId identifies an application resource
133 | * @return the Uri by which the application resource is accessed
134 | */
135 | @JvmStatic
136 | fun createRawRingtoneUri(context: Context, @AnyRes resourceId: Int): Uri = Uri.Builder()
137 | .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
138 | .authority(context.packageName)
139 | .path(resourceId.toString())
140 | .build()
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/Utils.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker
2 |
3 | import android.content.ActivityNotFoundException
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.graphics.drawable.Animatable
7 | import android.net.Uri
8 | import android.os.Build
9 | import android.view.View
10 | import android.widget.ImageView
11 | import android.widget.Toast
12 | import androidx.activity.result.ActivityResultLauncher
13 | import androidx.annotation.ChecksSdkIntAtLeast
14 | import androidx.core.content.ContextCompat
15 | import androidx.fragment.app.Fragment
16 | import androidx.navigation.NavOptions
17 | import androidx.navigation.navOptions
18 | import androidx.navigation.ui.R as RNavigation
19 |
20 | internal val RINGTONE_URI_SILENT: Uri = Uri.EMPTY
21 | internal val RINGTONE_URI_NULL: Uri = Uri.EMPTY
22 |
23 | internal const val TAG_RINGTONE_PICKER = "ringtone_picker"
24 | internal const val EXTRA_SETTINGS = "settings"
25 |
26 | internal const val ASSET_URI_PREFIX = "file:///android_asset/"
27 |
28 | internal const val EXTRA_CATEGORY_TYPE = "category_type"
29 | internal const val EXTRA_CATEGORY_ID = "category_id"
30 |
31 | internal fun Context.safeContext(): Context =
32 | takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !isDeviceProtectedStorage }?.let {
33 | ContextCompat.createDeviceProtectedStorageContext(it) ?: it
34 | } ?: this
35 |
36 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
37 | internal fun isOOrLater(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
38 |
39 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
40 | internal fun isQOrLater(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
41 |
42 | internal fun ImageView.startDrawableAnimation() {
43 | (drawable as? Animatable)?.start()
44 | }
45 |
46 | internal fun View.gone() {
47 | visibility = View.GONE
48 | }
49 |
50 | internal fun Fragment.requireRingtonePickerListener(): UltimateRingtonePicker.RingtonePickerListener =
51 | when {
52 | parentFragment is UltimateRingtonePicker.RingtonePickerListener -> parentFragment as UltimateRingtonePicker.RingtonePickerListener
53 | context is UltimateRingtonePicker.RingtonePickerListener -> context as UltimateRingtonePicker.RingtonePickerListener
54 | activity is UltimateRingtonePicker.RingtonePickerListener -> activity as UltimateRingtonePicker.RingtonePickerListener
55 | else -> throw IllegalStateException("Cannot find RingtonePickerListener")
56 | }
57 |
58 | internal fun ActivityResultLauncher.launchSaf(context: Context) {
59 | try {
60 | launch(
61 | Intent(Intent.ACTION_OPEN_DOCUMENT)
62 | .addCategory(Intent.CATEGORY_OPENABLE)
63 | .setType("audio/*")
64 | .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
65 | /**
66 | * The docs for SAF is quite vague. I add [Intent.FLAG_GRANT_READ_URI_PERMISSION]
67 | * flag following [Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION]'s doc.
68 | */
69 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
70 | )
71 | } catch (e: ActivityNotFoundException) {
72 | e.printStackTrace()
73 | Toast.makeText(context, e.message.toString(), Toast.LENGTH_LONG).show()
74 | }
75 | }
76 |
77 | internal fun createDefaultNavOptions(): NavOptions {
78 | return navOptions {
79 | anim {
80 | enter = RNavigation.animator.nav_default_enter_anim
81 | exit = RNavigation.animator.nav_default_exit_anim
82 | popEnter = RNavigation.animator.nav_default_pop_enter_anim
83 | popExit = RNavigation.animator.nav_default_pop_exit_anim
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/CustomRingtone.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2016 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 | package xyz.aprildown.ultimateringtonepicker.data
18 |
19 | import android.net.Uri
20 |
21 | /**
22 | * A read-only domain object representing a custom music chosen from the file system.
23 | */
24 | internal data class CustomRingtone(
25 | /**
26 | * The unique identifier of the custom music.
27 | */
28 | val id: Long,
29 | /**
30 | * The uri that allows playback of the music.
31 | */
32 | val uri: Uri,
33 | /**
34 | * The title describing the file at the given uri; typically the file name.
35 | */
36 | val title: String
37 | ) {
38 | /**
39 | * {@code true} iff the application has permission to read the content of {@code mUri uri}.
40 | */
41 | var hasPermissions: Boolean = true
42 |
43 | var canBeQueried: Boolean = true
44 | }
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/CustomRingtoneDAO.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2016 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 | package xyz.aprildown.ultimateringtonepicker.data
18 |
19 | import android.content.SharedPreferences
20 | import android.net.Uri
21 | import java.util.ArrayList
22 |
23 | /**
24 | * This class encapsulates the transfer of data between [CustomRingtone] domain objects and
25 | * their permanent storage in [SharedPreferences].
26 | */
27 | internal class CustomRingtoneDAO(private val prefs: SharedPreferences) {
28 |
29 | /**
30 | * @param uri points to an audio file located on the file system
31 | * @param title the title of the audio content at the given {@code uri}
32 | * @return the newly added custom ringtone
33 | */
34 | fun addCustomRingtone(uri: Uri, title: String): CustomRingtone {
35 | val id = prefs.getLong(NEXT_RINGTONE_ID, 0)
36 | val ids = getRingtoneIds()
37 | ids.add(id.toString())
38 |
39 | prefs.edit()
40 | .putString(RINGTONE_URI + id, uri.toString())
41 | .putString(RINGTONE_TITLE + id, title)
42 | .putLong(NEXT_RINGTONE_ID, id + 1)
43 | .putStringSet(RINGTONE_IDS, ids)
44 | .apply()
45 |
46 | return CustomRingtone(id, uri, title)
47 | }
48 |
49 | /**
50 | * @param id identifies the ringtone to be removed
51 | */
52 | fun removeCustomRingtone(id: Long) {
53 | val ids = getRingtoneIds()
54 | ids.remove(id.toString())
55 |
56 | val editor = prefs.edit()
57 | editor.remove(RINGTONE_URI + id)
58 | editor.remove(RINGTONE_TITLE + id)
59 | if (ids.isEmpty()) {
60 | editor.remove(RINGTONE_IDS)
61 | editor.remove(NEXT_RINGTONE_ID)
62 | } else {
63 | editor.putStringSet(RINGTONE_IDS, ids)
64 | }
65 | editor.apply()
66 | }
67 |
68 | /**
69 | * @return a list of all known custom ringtones
70 | */
71 | fun getCustomRingtones(): MutableList {
72 | val ids = prefs.getStringSet(RINGTONE_IDS, null) ?: return mutableListOf()
73 | val ringtones = ArrayList(ids.size)
74 |
75 | for (id in ids) {
76 | val idLong = id.toLongOrNull() ?: continue
77 | val uri = Uri.parse(prefs.getString(RINGTONE_URI + id, null) ?: continue)
78 | val title = prefs.getString(RINGTONE_TITLE + id, null) ?: continue
79 | ringtones.add(CustomRingtone(idLong, uri, title))
80 | }
81 |
82 | return ringtones
83 | }
84 |
85 | private fun getRingtoneIds(): MutableSet {
86 | return prefs.getStringSet(RINGTONE_IDS, null) ?: mutableSetOf()
87 | }
88 |
89 | companion object {
90 | /**
91 | * Key to a preference that stores the set of all custom ringtone ids.
92 | */
93 | private const val RINGTONE_IDS = "music_ids"
94 |
95 | /**
96 | * Key to a preference that stores the next unused ringtone id.
97 | */
98 | private const val NEXT_RINGTONE_ID = "next_music_id"
99 |
100 | /**
101 | * Prefix for a key to a preference that stores the URI associated with the ringtone id.
102 | */
103 | private const val RINGTONE_URI = "music_uri_"
104 |
105 | /**
106 | * Prefix for a key to a preference that stores the title associated with the ringtone id.
107 | */
108 | private const val RINGTONE_TITLE = "music_title_"
109 | }
110 | }
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/CustomRingtoneModel.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data
2 |
3 | import android.content.ContentResolver
4 | import android.content.Context
5 | import android.content.SharedPreferences
6 | import android.net.Uri
7 | import android.provider.BaseColumns
8 | import xyz.aprildown.ultimateringtonepicker.safeContext
9 |
10 | internal class CustomRingtoneModel(private val context: Context) {
11 |
12 | /**
13 | * Stores all custom ringtones that users select
14 | */
15 | private val customRingtoneDAO = CustomRingtoneDAO(context.getCustomRingtoneSharedPrefs())
16 |
17 | /**
18 | * A mutable copy of the custom ringtones.
19 | */
20 | private val ringtoneCache: MutableList by lazy {
21 | customRingtoneDAO.getCustomRingtones().apply {
22 | val cr = context.contentResolver
23 | forEach {
24 | it.canBeQueried = cr.canFind(it.uri)
25 | }
26 | val allPermissions = cr.persistedUriPermissions.mapNotNull { it?.uri }
27 | forEach {
28 | it.hasPermissions = it.uri in allPermissions
29 | }
30 | }
31 | }
32 |
33 | /**
34 | * User selects a custom ringtone and we store it in both shared preference and cache
35 | */
36 | fun addCustomRingtone(uri: Uri, title: String): CustomRingtone {
37 | // If the uri is already present in an existing ringtone, do nothing.
38 | val existing = getCustomRingtone(uri)
39 | if (existing != null) {
40 | return existing
41 | }
42 |
43 | val ringtone = customRingtoneDAO.addCustomRingtone(uri, title)
44 | ringtoneCache.add(ringtone)
45 |
46 | return ringtone
47 | }
48 |
49 | /**
50 | * Delete a custom ringtone in both shared preference and cache
51 | */
52 | fun removeCustomRingtone(uri: Uri) {
53 | getCustomRingtone(uri)?.let {
54 | customRingtoneDAO.removeCustomRingtone(it.id)
55 | ringtoneCache.remove(it)
56 | }
57 | }
58 |
59 | /**
60 | * Get all custom ringtones selected by users
61 | * @return an immutable list of ringtones that users select
62 | */
63 | fun getCustomRingtones(): List = ringtoneCache.map {
64 | Ringtone(
65 | it.uri,
66 | it.title,
67 | /**
68 | * If it canBeQueried, we have READ_EXTERNAL_STORAGE.
69 | * If it hasPermissions, it's from SAF.
70 | */
71 | isValid = it.canBeQueried || it.hasPermissions
72 | )
73 | }
74 |
75 | private fun getCustomRingtone(uri: Uri) = ringtoneCache.find { it.uri == uri }
76 | }
77 |
78 | private fun Context.getCustomRingtoneSharedPrefs(): SharedPreferences {
79 | return safeContext().getSharedPreferences(
80 | "music_picker_prefs", Context.MODE_PRIVATE
81 | )
82 | }
83 |
84 | private fun ContentResolver.canFind(uri: Uri): Boolean {
85 | return try {
86 | query(uri, arrayOf(BaseColumns._ID), null, null, null)?.use {
87 | it.moveToFirst() && it.count == 1
88 | } ?: false
89 | } catch (e: SecurityException) {
90 | // We even don't have the permission to query.
91 | false
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/DeviceRingtoneModel.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data
2 |
3 | import android.content.ContentUris
4 | import android.content.Context
5 | import android.provider.MediaStore
6 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
7 | import xyz.aprildown.ultimateringtonepicker.data.folder.RingtoneFolderRetrieverCompat
8 | import xyz.aprildown.ultimateringtonepicker.isQOrLater
9 |
10 | internal class DeviceRingtoneModel(private val context: Context) {
11 |
12 | fun getAllDeviceRingtones(): List {
13 | val data = mutableListOf()
14 | try {
15 | context.contentResolver.query(
16 | if (isQOrLater()) {
17 | MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
18 | } else {
19 | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
20 | },
21 | arrayOf(
22 | MediaStore.Audio.AudioColumns._ID,
23 | MediaStore.Audio.AudioColumns.TITLE,
24 | MediaStore.Audio.AudioColumns.ARTIST_ID,
25 | MediaStore.Audio.AudioColumns.ALBUM_ID
26 | ),
27 | """
28 | ${MediaStore.Audio.AudioColumns.IS_PODCAST} == 0 AND
29 | (
30 | ${MediaStore.Audio.AudioColumns.IS_MUSIC} != 0 OR
31 | ${MediaStore.Audio.AudioColumns.IS_ALARM} != 0 OR
32 | ${MediaStore.Audio.AudioColumns.IS_NOTIFICATION} != 0 OR
33 | ${MediaStore.Audio.AudioColumns.IS_RINGTONE} != 0
34 | )
35 | """.trimIndent(),
36 | null,
37 | MediaStore.Audio.Media.TITLE_KEY
38 | )?.use { cursor ->
39 | cursor.moveToPosition(-1)
40 | while (cursor.moveToNext()) {
41 | try {
42 | val uri = ContentUris.withAppendedId(
43 | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
44 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID))
45 | )
46 | val title =
47 | cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE))
48 | val artistId =
49 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST_ID))
50 | val albumId =
51 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID))
52 | data.add(Ringtone(uri, title, artistId, albumId))
53 | } catch (e: Exception) {
54 | e.printStackTrace()
55 | }
56 | }
57 | }
58 | } catch (e: Exception) {
59 | e.printStackTrace()
60 | }
61 | return data
62 | }
63 |
64 | private fun getArtists(): List {
65 | val data = mutableListOf()
66 | try {
67 | context.contentResolver.query(
68 | if (isQOrLater()) {
69 | MediaStore.Audio.Artists.getContentUri(MediaStore.VOLUME_EXTERNAL)
70 | } else {
71 | MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI
72 | },
73 | arrayOf(
74 | MediaStore.Audio.Artists._ID,
75 | MediaStore.Audio.Artists.ARTIST,
76 | MediaStore.Audio.Artists.NUMBER_OF_TRACKS
77 | ),
78 | null,
79 | null,
80 | MediaStore.Audio.Artists.ARTIST_KEY
81 | )?.use { cursor ->
82 | cursor.moveToPosition(-1)
83 | while (cursor.moveToNext()) {
84 | try {
85 | val id =
86 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Artists._ID))
87 | val name =
88 | cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Artists.ARTIST))
89 | val numOfTracks =
90 | cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Audio.Artists.NUMBER_OF_TRACKS))
91 | data.add(
92 | Category(
93 | type = UltimateRingtonePicker.RingtoneCategoryType.Artist,
94 | id = id,
95 | name = name,
96 | numberOfSongs = numOfTracks
97 | )
98 | )
99 | } catch (e: Exception) {
100 | e.printStackTrace()
101 | }
102 | }
103 | }
104 | } catch (e: Exception) {
105 | e.printStackTrace()
106 | }
107 | return data
108 | }
109 |
110 | private fun getAlbums(): List {
111 | val data = mutableListOf()
112 | try {
113 | context.contentResolver.query(
114 | if (isQOrLater()) {
115 | MediaStore.Audio.Albums.getContentUri(MediaStore.VOLUME_EXTERNAL)
116 | } else {
117 | MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
118 | },
119 | arrayOf(
120 | MediaStore.Audio.Albums._ID,
121 | MediaStore.Audio.Albums.ALBUM,
122 | MediaStore.Audio.Albums.NUMBER_OF_SONGS
123 | ),
124 | null,
125 | null,
126 | MediaStore.Audio.Albums.ALBUM_KEY
127 | )?.use { cursor ->
128 | cursor.moveToPosition(-1)
129 | while (cursor.moveToNext()) {
130 | try {
131 | val id =
132 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums._ID))
133 | val name =
134 | cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums.ALBUM))
135 | val numOfSongs =
136 | cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums.NUMBER_OF_SONGS))
137 | data.add(
138 | Category(
139 | type = UltimateRingtonePicker.RingtoneCategoryType.Album,
140 | id = id,
141 | name = name,
142 | numberOfSongs = numOfSongs
143 | )
144 | )
145 | } catch (e: Exception) {
146 | e.printStackTrace()
147 | }
148 | }
149 | }
150 | } catch (e: Exception) {
151 | e.printStackTrace()
152 | }
153 | return data
154 | }
155 |
156 | private fun getFolders(): List {
157 | return RingtoneFolderRetrieverCompat(context).getRingtoneFolders()
158 | }
159 |
160 | fun getFolderRingtones(folderId: Long): List {
161 | return RingtoneFolderRetrieverCompat(context).getRingtonesFromFolder(folderId)
162 | }
163 |
164 | fun getCategories(
165 | categoryType: UltimateRingtonePicker.RingtoneCategoryType
166 | ): List = when (categoryType) {
167 | UltimateRingtonePicker.RingtoneCategoryType.Artist -> getArtists()
168 | UltimateRingtonePicker.RingtoneCategoryType.Album -> getAlbums()
169 | UltimateRingtonePicker.RingtoneCategoryType.Folder -> getFolders()
170 | else -> throw IllegalArgumentException("Wrong category categoryType: $categoryType")
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/Models.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data
2 |
3 | import android.net.Uri
4 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
5 |
6 | internal data class Ringtone(
7 | val uri: Uri,
8 | val title: String,
9 | val artistId: Long? = null,
10 | val albumId: Long? = null,
11 | val isValid: Boolean = true
12 | )
13 |
14 | internal data class Category(
15 | val type: UltimateRingtonePicker.RingtoneCategoryType,
16 | val id: Long,
17 | val name: String,
18 | val numberOfSongs: Int
19 | )
20 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/SystemRingtoneModel.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data
2 |
3 | import android.content.Context
4 | import android.database.Cursor
5 | import android.database.MatrixCursor
6 | import android.media.RingtoneManager
7 | import android.net.Uri
8 | import androidx.collection.ArrayMap
9 | import xyz.aprildown.ultimateringtonepicker.R
10 | import xyz.aprildown.ultimateringtonepicker.RINGTONE_URI_NULL
11 |
12 | internal class SystemRingtoneModel(private val context: Context) {
13 |
14 | /**
15 | * Maps ringtone uri to ringtone title; looking up a title from scratch is expensive.
16 | */
17 | private val ringtoneTitles = ArrayMap(16)
18 |
19 | /**
20 | * @param types a list of of [RingtoneManager.TYPE_RINGTONE], [RingtoneManager.TYPE_NOTIFICATION],
21 | * and [RingtoneManager.TYPE_ALARM]
22 | */
23 | fun preloadRingtoneTitles(types: List) {
24 | // Early return if the cache is already primed.
25 | if (!ringtoneTitles.isEmpty) {
26 | return
27 | }
28 |
29 | for (type in types) {
30 | if (!type.isValidRingtoneManagerType()) continue
31 |
32 | val ringtoneManager = RingtoneManager(context)
33 | ringtoneManager.setType(type)
34 | // Cache a title for each system ringtone.
35 | try {
36 | // RingtoneManager.getCursor says we shouldn't close the cursor.
37 | val cursor = ringtoneManager.cursor
38 | cursor.moveToFirst()
39 | while (!cursor.isAfterLast) {
40 | val ringtoneTitle = cursor.getString(RingtoneManager.TITLE_COLUMN_INDEX)
41 | val ringtoneUri = ringtoneManager.getRingtoneUri(cursor.position)
42 | ringtoneTitles[ringtoneUri] = ringtoneTitle
43 | cursor.moveToNext()
44 | }
45 | } catch (ignored: Throwable) {
46 | // best attempt only
47 | }
48 | }
49 | }
50 |
51 | fun getRingtoneTitle(uri: Uri): String {
52 | // Special case: no ringtone has a title of "Silent".
53 | if (RINGTONE_URI_NULL == uri) {
54 | return context.getString(R.string.urp_silent_ringtone_title)
55 | }
56 |
57 | // Check the cache.
58 | var title: String? = ringtoneTitles[uri]
59 |
60 | if (title == null) {
61 | // This is slow because a media player is created during Ringtone object creation.
62 | title = RingtoneManager.getRingtone(context, uri)?.getTitle(context)
63 | ?: context.getString(R.string.urp_unknown_ringtone_title)
64 | // Cache the title for later use.
65 | ringtoneTitles[uri] = title
66 | }
67 | return title
68 | }
69 |
70 | /**
71 | * Retrieve all system [type] ringtones
72 | */
73 | fun getRingtones(type: Int): List {
74 | if (!type.isValidRingtoneManagerType()) return emptyList()
75 |
76 | val result = mutableListOf()
77 |
78 | // Fetch the standard system ringtones.
79 | val ringtoneManager = RingtoneManager(context)
80 | ringtoneManager.setType(type)
81 |
82 | val systemRingtoneCursor: Cursor = try {
83 | ringtoneManager.cursor
84 | } catch (e: Exception) {
85 | // Could not get system ringtone cursor
86 | MatrixCursor(arrayOf())
87 | }
88 |
89 | // Add an item holder for each system ringtone.
90 | for (i in 0 until systemRingtoneCursor.count) {
91 | result.add(ringtoneManager.getRingtoneUri(i))
92 | }
93 |
94 | return result
95 | }
96 | }
97 |
98 | private fun Int.isValidRingtoneManagerType(): Boolean =
99 | this == RingtoneManager.TYPE_RINGTONE ||
100 | this == RingtoneManager.TYPE_NOTIFICATION ||
101 | this == RingtoneManager.TYPE_ALARM
102 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/folder/RingtoneFolderRetriever.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data.folder
2 |
3 | import xyz.aprildown.ultimateringtonepicker.data.Category
4 | import xyz.aprildown.ultimateringtonepicker.data.Ringtone
5 |
6 | internal interface RingtoneFolderRetriever {
7 | fun getRingtoneFolders(): List
8 | fun getRingtonesFromFolder(folderId: Long): List
9 | }
10 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/folder/RingtoneFolderRetrieverCompat.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data.folder
2 |
3 | import android.content.Context
4 | import xyz.aprildown.ultimateringtonepicker.isQOrLater
5 |
6 | internal class RingtoneFolderRetrieverCompat(
7 | private val context: Context
8 | ) : RingtoneFolderRetriever by when {
9 | isQOrLater() -> RingtoneFolderRetrieverQ(context)
10 | else -> RingtoneFolderRetrieverPreQ(context)
11 | }
12 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/folder/RingtoneFolderRetrieverPreQ.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data.folder
2 |
3 | import android.content.ContentUris
4 | import android.content.Context
5 | import android.database.Cursor
6 | import android.provider.MediaStore
7 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
8 | import xyz.aprildown.ultimateringtonepicker.data.Category
9 | import xyz.aprildown.ultimateringtonepicker.data.Ringtone
10 |
11 | internal class RingtoneFolderRetrieverPreQ(private val context: Context) : RingtoneFolderRetriever {
12 | override fun getRingtoneFolders(): List {
13 | val data = mutableListOf()
14 | try {
15 | // This is hack. Is there any better way?
16 | @Suppress("DEPRECATION")
17 | context.contentResolver.query(
18 | MediaStore.Files.getContentUri("external"),
19 | arrayOf(
20 | MediaStore.Files.FileColumns.PARENT,
21 | // MediaStore.Files.FileColumns.DISPLAY_NAME,
22 | "COUNT(${MediaStore.Files.FileColumns.DATA}) AS dataCount"
23 | ),
24 | """
25 | ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ${MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO}
26 | ) GROUP BY (${MediaStore.Files.FileColumns.PARENT}
27 | """.trimIndent(),
28 | null,
29 | MediaStore.Files.FileColumns.TITLE
30 | )?.use { cursor ->
31 | cursor.moveToPosition(-1)
32 | while (cursor.moveToNext()) {
33 | try {
34 | val parentId =
35 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.PARENT))
36 | val numOfSongs =
37 | cursor.getInt(cursor.getColumnIndexOrThrow("dataCount"))
38 |
39 | context.contentResolver.query(
40 | ContentUris.withAppendedId(
41 | MediaStore.Files.getContentUri("external"),
42 | parentId
43 | ),
44 | arrayOf(MediaStore.Files.FileColumns.TITLE),
45 | null,
46 | null,
47 | null
48 | )?.use { parentCursor ->
49 | parentCursor.moveToPosition(-1)
50 | while (parentCursor.moveToNext()) {
51 | try {
52 | val parentTitle = parentCursor.getString(
53 | parentCursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.TITLE)
54 | )
55 | if (parentTitle != null) {
56 | data.add(
57 | Category(
58 | type = UltimateRingtonePicker.RingtoneCategoryType.Folder,
59 | id = parentId,
60 | name = parentTitle,
61 | numberOfSongs = numOfSongs
62 | )
63 | )
64 | }
65 | } catch (e: Exception) {
66 | e.printStackTrace()
67 | }
68 | }
69 | }
70 | } catch (e: Exception) {
71 | e.printStackTrace()
72 | }
73 | }
74 | }
75 | } catch (e: Exception) {
76 | e.printStackTrace()
77 | }
78 | return data
79 | }
80 |
81 | override fun getRingtonesFromFolder(folderId: Long): List {
82 | val data = mutableListOf()
83 | try {
84 | context.contentResolver.query(
85 | MediaStore.Files.getContentUri("external"),
86 | arrayOf(
87 | MediaStore.Audio.Media._ID,
88 | MediaStore.Audio.Media.TITLE
89 | ),
90 | """
91 | ${MediaStore.Files.FileColumns.PARENT} = $folderId AND
92 | (
93 | ${MediaStore.Files.FileColumns.MIME_TYPE} LIKE 'audio%' OR
94 | ${MediaStore.Files.FileColumns.MIME_TYPE} LIKE 'application/ogg'
95 | )
96 | """.trimIndent(),
97 | null,
98 | MediaStore.Audio.Media.TITLE_KEY
99 | )?.use { cursor: Cursor ->
100 | cursor.moveToPosition(-1)
101 | while (cursor.moveToNext()) {
102 | try {
103 | val uri = ContentUris.withAppendedId(
104 | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
105 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID))
106 | )
107 | val title =
108 | cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE))
109 | data.add(Ringtone(uri, title))
110 | } catch (e: Exception) {
111 | e.printStackTrace()
112 | }
113 | }
114 | }
115 | } catch (e: Exception) {
116 | e.printStackTrace()
117 | }
118 | return data
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/data/folder/RingtoneFolderRetrieverQ.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.data.folder
2 |
3 | import android.content.ContentUris
4 | import android.content.Context
5 | import android.database.Cursor
6 | import android.os.Build
7 | import android.provider.MediaStore
8 | import androidx.annotation.RequiresApi
9 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
10 | import xyz.aprildown.ultimateringtonepicker.data.Category
11 | import xyz.aprildown.ultimateringtonepicker.data.Ringtone
12 |
13 | @RequiresApi(Build.VERSION_CODES.Q)
14 | internal class RingtoneFolderRetrieverQ(private val context: Context) : RingtoneFolderRetriever {
15 |
16 | private data class MutableFolder(
17 | val folderId: Long,
18 | val folderName: String,
19 | var count: Int = 0
20 | )
21 |
22 | override fun getRingtoneFolders(): List {
23 | val folders = mutableListOf()
24 | try {
25 | context.contentResolver.query(
26 | MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL),
27 | arrayOf(
28 | MediaStore.Audio.Media.BUCKET_ID,
29 | MediaStore.Audio.Media.BUCKET_DISPLAY_NAME
30 | ),
31 | null,
32 | null,
33 | MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME
34 | )?.use { cursor: Cursor ->
35 | cursor.moveToPosition(-1)
36 | while (cursor.moveToNext()) {
37 | try {
38 | val bucketId =
39 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.BUCKET_ID))
40 | val currentFolder = folders.find { folder -> folder.folderId == bucketId }
41 | if (currentFolder == null) {
42 | val bucketName = try {
43 | cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.BUCKET_DISPLAY_NAME))
44 | } catch (e: Exception) {
45 | // The bucketName may be null or doesn't exist.
46 | continue
47 | }
48 | folders.add(MutableFolder(bucketId, bucketName, count = 1))
49 | } else {
50 | currentFolder.count += 1
51 | }
52 | } catch (e: Exception) {
53 | e.printStackTrace()
54 | }
55 | }
56 | }
57 | } catch (e: Exception) {
58 | e.printStackTrace()
59 | }
60 | return folders.map {
61 | Category(
62 | type = UltimateRingtonePicker.RingtoneCategoryType.Folder,
63 | id = it.folderId,
64 | name = it.folderName,
65 | numberOfSongs = it.count
66 | )
67 | }
68 | }
69 |
70 | override fun getRingtonesFromFolder(folderId: Long): List {
71 | val data = mutableListOf()
72 | try {
73 | context.contentResolver.query(
74 | MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL),
75 | arrayOf(
76 | MediaStore.Audio.Media._ID,
77 | MediaStore.Audio.Media.TITLE
78 | ),
79 | "${MediaStore.Audio.Media.BUCKET_ID} = $folderId",
80 | null,
81 | MediaStore.Audio.Media.TITLE_KEY
82 | )?.use { cursor: Cursor ->
83 | cursor.moveToPosition(-1)
84 | while (cursor.moveToNext()) {
85 | try {
86 | val uri = ContentUris.withAppendedId(
87 | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
88 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID))
89 | )
90 | val title =
91 | cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE))
92 | data.add(Ringtone(uri, title))
93 | } catch (e: Exception) {
94 | e.printStackTrace()
95 | }
96 | }
97 | }
98 | } catch (e: Exception) {
99 | e.printStackTrace()
100 | }
101 | return data
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/music/AsyncRingtonePlayer.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.music
2 |
3 | import android.annotation.TargetApi
4 | import android.content.Context
5 | import android.media.AudioAttributes
6 | import android.media.AudioFocusRequest
7 | import android.media.AudioManager
8 | import android.media.AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
9 | import android.media.AudioManager.AUDIOFOCUS_LOSS
10 | import android.media.AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
11 | import android.media.AudioManager.OnAudioFocusChangeListener
12 | import android.media.MediaPlayer
13 | import android.media.RingtoneManager
14 | import android.net.Uri
15 | import android.os.Build
16 | import android.os.Bundle
17 | import android.os.Handler
18 | import android.os.HandlerThread
19 | import android.os.Looper
20 | import android.os.Message
21 | import androidx.annotation.RequiresApi
22 | import androidx.core.os.BundleCompat
23 | import xyz.aprildown.ultimateringtonepicker.ASSET_URI_PREFIX
24 | import xyz.aprildown.ultimateringtonepicker.isOOrLater
25 | import java.io.IOException
26 |
27 | /**
28 | * This class controls playback of ringtones. Uses [MediaPlayer] in a
29 | * dedicated thread so that this class can be called from the main thread. Consequently, problems
30 | * controlling the ringtone do not cause ANRs in the main thread of the application.
31 | *
32 | * Ringtone playback is accomplished using
33 | * [MediaPlayer]. android.permission.READ_EXTERNAL_STORAGE is required to play custom
34 | * ringtones located on the SD card using this mechanism.
35 | */
36 | internal class AsyncRingtonePlayer(
37 | /** The context. */
38 | private val mContext: Context
39 | ) {
40 |
41 | companion object {
42 | // Message codes used with the ringtone thread.
43 | private const val EVENT_PLAY = 1
44 | private const val EVENT_STOP = 2
45 | private const val RINGTONE_URI_KEY = "RINGTONE_URI_KEY"
46 | private const val LOOP = "LOOP"
47 | private const val STREAM_TYPE = "STREAM_TYPE"
48 | }
49 |
50 | /** Handler running on the ringtone thread. */
51 | private val mHandler: Handler =
52 | object : Handler(HandlerThread("ringtone-player").apply { start() }.looper) {
53 | override fun handleMessage(msg: Message) {
54 | when (msg.what) {
55 | EVENT_PLAY -> {
56 | val data = msg.data
57 | val uri =
58 | BundleCompat.getParcelable(data, RINGTONE_URI_KEY, Uri::class.java)
59 | if (uri != mPlaybackDelegate.currentPlayingUri) {
60 | mPlaybackDelegate.stop(mContext)
61 | mPlaybackDelegate.play(
62 | mContext,
63 | uri,
64 | data.getBoolean(LOOP),
65 | data.getInt(STREAM_TYPE)
66 | )
67 | }
68 | }
69 | EVENT_STOP -> mPlaybackDelegate.stop(mContext)
70 | }
71 | }
72 | }
73 |
74 | private val mPlaybackDelegate: PlaybackDelegate by lazy {
75 | MediaPlayerPlaybackDelegate()
76 | }
77 |
78 | fun play(ringtoneUri: Uri, loop: Boolean, streamType: Int) {
79 | postMessage(EVENT_PLAY, ringtoneUri, loop, streamType)
80 | }
81 |
82 | fun stop() {
83 | postMessage(EVENT_STOP, null, false, 0)
84 | }
85 |
86 | val currentPlayingUri: Uri? get() = mPlaybackDelegate.currentPlayingUri
87 |
88 | /**
89 | * Posts a message to the ringtone-thread handler.
90 | *
91 | * @param messageCode the message to post
92 | * @param ringtoneUri the ringtone in question, if any
93 | */
94 | private fun postMessage(messageCode: Int, ringtoneUri: Uri?, loop: Boolean, streamType: Int) {
95 | synchronized(this) {
96 | val message = mHandler.obtainMessage(messageCode)
97 | if (ringtoneUri != null) {
98 | val bundle = Bundle()
99 | bundle.putParcelable(RINGTONE_URI_KEY, ringtoneUri)
100 | bundle.putBoolean(LOOP, loop)
101 | bundle.putInt(STREAM_TYPE, streamType)
102 | message.data = bundle
103 | }
104 |
105 | mHandler.sendMessage(message)
106 | }
107 | }
108 |
109 | private fun checkAsyncRingtonePlayerThread() {
110 | check(Looper.myLooper() == mHandler.looper) {
111 | "Must be on the AsyncRingtonePlayer thread!"
112 | }
113 | }
114 |
115 | /**
116 | * This interface abstracts away the differences between playing ringtones via [MediaPlayer].
117 | */
118 | private interface PlaybackDelegate {
119 |
120 | var currentPlayingUri: Uri?
121 |
122 | fun play(context: Context, ringtoneUri: Uri?, loop: Boolean, streamType: Int)
123 |
124 | fun stop(context: Context)
125 | }
126 |
127 | /**
128 | * Loops playback of a ringtone using [MediaPlayer].
129 | */
130 | private inner class MediaPlayerPlaybackDelegate : PlaybackDelegate, OnAudioFocusChangeListener {
131 |
132 | /** The audio focus manager. Only used by the ringtone thread. */
133 | private var mAudioManager: AudioManager? = null
134 |
135 | /** Non-`null` while playing a ringtone; `null` otherwise. */
136 | private var mMediaPlayer: MediaPlayer? = null
137 |
138 | private var mLoop: Boolean = false
139 | private var mStreamType: Int = 0
140 | private var audioAttributes: AudioAttributes? = null
141 |
142 | /**
143 | * Starts the actual playback of the ringtone. Executes on ringtone-thread.
144 | */
145 |
146 | override var currentPlayingUri: Uri? = null
147 |
148 | override fun play(context: Context, ringtoneUri: Uri?, loop: Boolean, streamType: Int) {
149 | checkAsyncRingtonePlayerThread()
150 | mLoop = loop
151 | mStreamType = streamType
152 | audioAttributes = AudioAttributes.Builder()
153 | .setLegacyStreamType(mStreamType)
154 | .build()
155 |
156 | if (mAudioManager == null) {
157 | mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
158 | }
159 |
160 | var alarmNoise: Uri? = ringtoneUri
161 | // Fall back to the system default alarm if the database does not have an alarm stored.
162 | if (alarmNoise == null) {
163 | alarmNoise = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
164 | }
165 |
166 | mMediaPlayer = MediaPlayer()
167 | mMediaPlayer?.setOnErrorListener { _, _, _ ->
168 | stop(context)
169 | true
170 | }
171 |
172 | try {
173 | // If alarmNoise is a custom ringtone on the sd card the app must be granted
174 | // android.permission.READ_EXTERNAL_STORAGE. Pre-M this is ensured at app
175 | // installation time. M+, this permission can be revoked by the user any time.
176 | currentPlayingUri = alarmNoise
177 |
178 | when {
179 | alarmNoise?.toString()?.startsWith(ASSET_URI_PREFIX) == true -> {
180 | val fileName = alarmNoise.toString().removePrefix(ASSET_URI_PREFIX)
181 | mContext.assets.openFd(fileName).use { afd ->
182 | mMediaPlayer?.setDataSource(
183 | afd.fileDescriptor,
184 | afd.startOffset,
185 | afd.length
186 | )
187 | }
188 | }
189 | else -> {
190 | mMediaPlayer?.setDataSource(context, alarmNoise!!)
191 | }
192 |
193 | }
194 |
195 |
196 | startPlayback()
197 | } catch (t: Throwable) {
198 | currentPlayingUri = null
199 | // The alarmNoise may be on the sd card which could be busy right now.
200 | try {
201 | // Must reset the media player to clear the error state.
202 | mMediaPlayer?.reset()
203 | } catch (t2: Throwable) {
204 | // At this point we just don't play anything.
205 | }
206 | }
207 | }
208 |
209 | /**
210 | * Prepare the MediaPlayer for playback if the alarm stream is not muted, then start the
211 | * playback.
212 | *
213 | * @return `true` if a crescendo has started and future volume adjustments are
214 | * required to advance the crescendo effect
215 | */
216 | @Throws(IOException::class)
217 | private fun startPlayback() {
218 | // Do not play alarms if stream volume is 0 (typically because ringer mode is silent).
219 | if (mAudioManager?.getStreamVolume(mStreamType) == 0) {
220 | return
221 | }
222 |
223 | // Indicate the ringtone should be played via the alarm stream.
224 | mMediaPlayer?.setAudioAttributes(audioAttributes)
225 |
226 | mMediaPlayer?.run {
227 | isLooping = mLoop
228 | if (!mLoop) {
229 | setOnCompletionListener {
230 | stop(mContext)
231 | }
232 | }
233 | prepare()
234 | if (isOOrLater()) {
235 | mAudioManager?.requestAudioFocus(createAudioFocusRequest(audioAttributes!!))
236 | } else {
237 | @Suppress("DEPRECATION")
238 | mAudioManager?.requestAudioFocus(
239 | this@MediaPlayerPlaybackDelegate,
240 | mStreamType, AUDIOFOCUS_GAIN_TRANSIENT
241 | )
242 | }
243 |
244 | start()
245 | }
246 | }
247 |
248 | override fun onAudioFocusChange(focusChange: Int) {
249 | when (focusChange) {
250 | AUDIOFOCUS_LOSS, AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> stop()
251 | }
252 | }
253 |
254 | /**
255 | * Stops the playback of the ringtone. Executes on the ringtone-thread.
256 | */
257 | override fun stop(context: Context) {
258 | checkAsyncRingtonePlayerThread()
259 |
260 | currentPlayingUri = null
261 |
262 | // Stop audio playing
263 | if (mMediaPlayer != null) {
264 | mMediaPlayer?.stop()
265 | mMediaPlayer?.release()
266 | mMediaPlayer = null
267 | }
268 |
269 | if (isOOrLater()) {
270 | if (audioAttributes != null) {
271 | mAudioManager?.abandonAudioFocusRequest(
272 | createAudioFocusRequest(audioAttributes!!)
273 | )
274 | }
275 | } else {
276 | @Suppress("DEPRECATION")
277 | mAudioManager?.abandonAudioFocus(this)
278 | }
279 | }
280 |
281 | @TargetApi(Build.VERSION_CODES.O)
282 | @RequiresApi(Build.VERSION_CODES.O)
283 | private fun createAudioFocusRequest(aa: AudioAttributes): AudioFocusRequest {
284 | return AudioFocusRequest.Builder(AUDIOFOCUS_GAIN_TRANSIENT)
285 | .setOnAudioFocusChangeListener(this@MediaPlayerPlaybackDelegate)
286 | .setAcceptsDelayedFocusGain(false)
287 | .setWillPauseWhenDucked(false)
288 | .setAudioAttributes(aa)
289 | .build()
290 | }
291 | }
292 | }
293 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/CategoryFragment.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 | import androidx.navigation.fragment.findNavController
7 | import androidx.navigation.navGraphViewModels
8 | import androidx.recyclerview.widget.DividerItemDecoration
9 | import com.mikepenz.fastadapter.FastAdapter
10 | import com.mikepenz.fastadapter.adapters.GenericItemAdapter
11 | import xyz.aprildown.ultimateringtonepicker.EXTRA_CATEGORY_ID
12 | import xyz.aprildown.ultimateringtonepicker.EXTRA_CATEGORY_TYPE
13 | import xyz.aprildown.ultimateringtonepicker.R
14 | import xyz.aprildown.ultimateringtonepicker.RingtonePickerViewModel
15 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
16 | import xyz.aprildown.ultimateringtonepicker.createDefaultNavOptions
17 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpRecyclerViewBinding
18 |
19 | internal class CategoryFragment : Fragment(R.layout.urp_recycler_view) {
20 |
21 | private val viewModel by navGraphViewModels(R.id.urp_nav_graph)
22 |
23 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
24 | val context = view.context
25 | val binding = UrpRecyclerViewBinding.bind(view)
26 |
27 | val categoryType =
28 | requireArguments().getInt(EXTRA_CATEGORY_TYPE, -1).let { type ->
29 | UltimateRingtonePicker.RingtoneCategoryType.entries.first { it.ordinal == type }
30 | }
31 |
32 | val itemAdapter = GenericItemAdapter()
33 | val fastAdapter = FastAdapter.with(itemAdapter)
34 | fastAdapter.onClickListener = { _, _, item, _ ->
35 | when (item) {
36 | is VisibleCategory -> {
37 | findNavController().navigate(
38 | R.id.urp_dest_ringtone_list,
39 | Bundle().apply {
40 | putInt(EXTRA_CATEGORY_TYPE, categoryType.ordinal)
41 | putLong(EXTRA_CATEGORY_ID, item.category.id)
42 | },
43 | createDefaultNavOptions()
44 | )
45 | true
46 | }
47 | else -> false
48 | }
49 | }
50 |
51 | binding.urpRecyclerView.run {
52 | adapter = fastAdapter
53 | addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
54 | }
55 |
56 | viewModel.getCategoryLiveData(categoryType)?.observe(viewLifecycleOwner) { categories ->
57 | binding.urpProgress.hide()
58 | if (categories.isNotEmpty()) {
59 | itemAdapter.setNewList(categories.map { category ->
60 | VisibleCategory(
61 | category = category,
62 | primaryText = category.name,
63 | secondaryText = category.numberOfSongs.toString()
64 | )
65 | })
66 | } else {
67 | itemAdapter.setNewList(listOf(VisibleEmptyView()))
68 | }
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/DeviceRingtoneFragment.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.app.Activity
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.view.View
7 | import androidx.activity.result.contract.ActivityResultContracts
8 | import androidx.fragment.app.Fragment
9 | import androidx.lifecycle.DefaultLifecycleObserver
10 | import androidx.lifecycle.LifecycleOwner
11 | import androidx.lifecycle.Observer
12 | import androidx.navigation.fragment.findNavController
13 | import androidx.navigation.navGraphViewModels
14 | import androidx.viewpager2.adapter.FragmentStateAdapter
15 | import androidx.viewpager2.widget.ViewPager2
16 | import com.google.android.material.tabs.TabLayoutMediator
17 | import xyz.aprildown.ultimateringtonepicker.EXTRA_CATEGORY_TYPE
18 | import xyz.aprildown.ultimateringtonepicker.R
19 | import xyz.aprildown.ultimateringtonepicker.RingtonePickerViewModel
20 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
21 | import xyz.aprildown.ultimateringtonepicker.data.Ringtone
22 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpFragmentDeviceRingtoneBinding
23 | import xyz.aprildown.ultimateringtonepicker.gone
24 | import xyz.aprildown.ultimateringtonepicker.launchSaf
25 |
26 | internal class DeviceRingtoneFragment :
27 | Fragment(R.layout.urp_fragment_device_ringtone), EventHandler {
28 |
29 | private val viewModel by navGraphViewModels(R.id.urp_nav_graph)
30 |
31 | private val safLauncher =
32 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
33 | onSafResult(resultCode = it.resultCode, data = it.data)
34 | }
35 |
36 | init {
37 | lifecycle.addObserver(
38 | object : DefaultLifecycleObserver {
39 | override fun onResume(owner: LifecycleOwner) {
40 | super.onResume(owner)
41 | lifecycle.removeObserver(this)
42 |
43 | if (viewModel.settings.deviceRingtonePicker?.alwaysUseSaf == true) {
44 | safLauncher.launchSaf(requireContext())
45 | } else {
46 | viewModel.allDeviceRingtones.observe(
47 | this@DeviceRingtoneFragment,
48 | object : Observer> {
49 | override fun onChanged(value: List) {
50 | viewModel.allDeviceRingtones.removeObserver(this)
51 | if (value.isEmpty()) {
52 | safLauncher.launchSaf(requireContext())
53 | }
54 | }
55 | }
56 | )
57 | }
58 | }
59 | }
60 | )
61 | }
62 |
63 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
64 | val binding = UrpFragmentDeviceRingtoneBinding.bind(view)
65 |
66 | val deviceRingtonesTypes =
67 | viewModel.settings.deviceRingtonePicker?.deviceRingtoneTypes ?: emptyList()
68 |
69 | binding.urpDeviceViewPager.adapter = CategoryAdapter(this, deviceRingtonesTypes)
70 | binding.urpDeviceViewPager.registerOnPageChangeCallback(
71 | object : ViewPager2.OnPageChangeCallback() {
72 | override fun onPageSelected(position: Int) {
73 | viewModel.stopPlaying()
74 | }
75 | }
76 | )
77 |
78 | if (deviceRingtonesTypes.size == 1) {
79 | binding.urpDeviceTabLayout.gone()
80 | }
81 |
82 | TabLayoutMediator(binding.urpDeviceTabLayout, binding.urpDeviceViewPager) { tab, position ->
83 | tab.text = when (position) {
84 | 0 -> getString(R.string.urp_ringtone)
85 | 1 -> getString(R.string.urp_artist)
86 | 2 -> getString(R.string.urp_album)
87 | 3 -> getString(R.string.urp_folder)
88 | else -> null
89 | }
90 | }.attach()
91 | }
92 |
93 | /**
94 | * MediaStore returns nothing or we request it, launch SAF.
95 | */
96 | private fun onSafResult(resultCode: Int, data: Intent?) {
97 | val hasSystemPicker = viewModel.settings.systemRingtonePicker != null
98 |
99 | fun onNothingSelected() {
100 | if (hasSystemPicker) {
101 | findNavController().popBackStack()
102 | } else {
103 | viewModel.onFinalSelection(emptyList())
104 | }
105 | }
106 |
107 | if (resultCode == Activity.RESULT_OK && data != null) {
108 | val selected = viewModel.onSafSelect(requireContext().contentResolver, data)
109 | if (selected != null) {
110 | if (hasSystemPicker) {
111 | viewModel.onDeviceSelection(listOf(selected))
112 | findNavController().popBackStack(R.id.urp_dest_system, false)
113 | } else {
114 | viewModel.onFinalSelection(listOf(selected))
115 | }
116 | } else {
117 | onNothingSelected()
118 | }
119 | } else {
120 | onNothingSelected()
121 | }
122 | }
123 |
124 | override fun onSelect() {
125 | RingtoneFragment.myself?.onSelect()
126 | }
127 |
128 | override fun onBack(): Boolean {
129 | viewModel.stopPlaying()
130 | return if (viewModel.settings.systemRingtonePicker == null) {
131 | false
132 | } else {
133 | findNavController().popBackStack()
134 | }
135 | }
136 | }
137 |
138 | private class CategoryAdapter(
139 | fragment: Fragment,
140 | private val deviceRingtoneTypes: List
141 | ) : FragmentStateAdapter(
142 | fragment.childFragmentManager,
143 | fragment.viewLifecycleOwner.lifecycle
144 | ) {
145 |
146 | override fun getItemCount(): Int = deviceRingtoneTypes.size
147 |
148 | override fun createFragment(position: Int): Fragment {
149 | val type = deviceRingtoneTypes[position]
150 | return when (type) {
151 | UltimateRingtonePicker.RingtoneCategoryType.All -> RingtoneFragment()
152 | UltimateRingtonePicker.RingtoneCategoryType.Artist -> CategoryFragment()
153 | UltimateRingtonePicker.RingtoneCategoryType.Album -> CategoryFragment()
154 | UltimateRingtonePicker.RingtoneCategoryType.Folder -> CategoryFragment()
155 | }.apply {
156 | arguments = Bundle().apply {
157 | putInt(EXTRA_CATEGORY_TYPE, type.ordinal)
158 | }
159 | }
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/EventHandler.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | internal interface EventHandler {
4 | fun onSelect()
5 |
6 | /**
7 | * @return If the event consumed.
8 | */
9 | fun onBack(): Boolean
10 | }
11 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/RecyclerViewUtils.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import androidx.fragment.app.Fragment
4 | import androidx.recyclerview.widget.RecyclerView
5 | import com.mikepenz.fastadapter.FastAdapter
6 | import com.mikepenz.fastadapter.GenericFastAdapter
7 | import com.mikepenz.fastadapter.GenericItem
8 | import com.mikepenz.fastadapter.ISelectionListener
9 | import com.mikepenz.fastadapter.select.SelectExtension
10 | import com.mikepenz.fastadapter.select.getSelectExtension
11 | import xyz.aprildown.ultimateringtonepicker.R
12 | import xyz.aprildown.ultimateringtonepicker.RINGTONE_URI_SILENT
13 | import xyz.aprildown.ultimateringtonepicker.RingtonePickerViewModel
14 |
15 | internal fun FastAdapter.setUpSelectableRingtoneExtension(
16 | viewModel: RingtonePickerViewModel,
17 | onSelectionChanged: ((item: VisibleRingtone, selected: Boolean) -> Unit)? = null
18 | ): SelectExtension = getSelectExtension().also { selectExtension ->
19 |
20 | selectExtension.isSelectable = true
21 | selectExtension.multiSelect = viewModel.settings.enableMultiSelect
22 | selectExtension.selectWithItemUpdate = true
23 |
24 | selectExtension.selectionListener = object : ISelectionListener {
25 | override fun onSelectionChanged(item: GenericItem, selected: Boolean) {
26 | if (item !is VisibleRingtone) return
27 |
28 | // Clicked ringtone item
29 | val itemPosition = getPosition(item)
30 | if (selected) {
31 | if (item.ringtone.uri != RINGTONE_URI_SILENT) {
32 | item.isPlaying = true
33 | notifyItemChanged(itemPosition)
34 | viewModel.startPlaying(item.ringtone.uri)
35 | }
36 | // Stop other playing items
37 | if (selectExtension.multiSelect) {
38 | forEachIndexed { currentItem, position ->
39 | if (currentItem.isSelected &&
40 | currentItem is VisibleRingtone &&
41 | currentItem != item
42 | ) {
43 | currentItem.isPlaying = false
44 | notifyItemChanged(position)
45 | }
46 | }
47 | }
48 | } else {
49 | item.isPlaying = false
50 | notifyItemChanged(itemPosition)
51 | viewModel.stopPlaying()
52 | }
53 |
54 | onSelectionChanged?.invoke(item, selected)
55 | }
56 | }
57 | }
58 |
59 | internal val Fragment.ringtoneRecyclerView: RecyclerView? get() = view?.findViewById(R.id.urpRecyclerView)
60 |
61 | @Suppress("UNCHECKED_CAST")
62 | internal val Fragment.ringtoneFastAdapter: GenericFastAdapter?
63 | get() = ringtoneRecyclerView?.adapter as? GenericFastAdapter
64 |
65 | internal fun - FastAdapter
- .forEachIndexed(f: (item: Item, position: Int) -> Unit) {
66 | for (index in 0 until itemCount) {
67 | f.invoke(getItem(index) ?: continue, index)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/RingtoneFragment.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import androidx.fragment.app.Fragment
6 | import androidx.navigation.fragment.findNavController
7 | import androidx.navigation.navGraphViewModels
8 | import com.mikepenz.fastadapter.FastAdapter
9 | import com.mikepenz.fastadapter.adapters.GenericItemAdapter
10 | import com.mikepenz.fastadapter.select.getSelectExtension
11 | import xyz.aprildown.ultimateringtonepicker.EXTRA_CATEGORY_ID
12 | import xyz.aprildown.ultimateringtonepicker.EXTRA_CATEGORY_TYPE
13 | import xyz.aprildown.ultimateringtonepicker.R
14 | import xyz.aprildown.ultimateringtonepicker.RingtonePickerViewModel
15 | import xyz.aprildown.ultimateringtonepicker.UltimateRingtonePicker
16 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpRecyclerViewBinding
17 |
18 | internal class RingtoneFragment : Fragment(R.layout.urp_recycler_view), EventHandler {
19 |
20 | private val viewModel by navGraphViewModels(R.id.urp_nav_graph)
21 |
22 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
23 | val binding = UrpRecyclerViewBinding.bind(view)
24 |
25 | val itemAdapter = GenericItemAdapter()
26 | val fastAdapter = FastAdapter.with(itemAdapter)
27 | fastAdapter.setUpSelectableRingtoneExtension(viewModel)
28 |
29 | binding.urpRecyclerView.adapter = fastAdapter
30 |
31 | val arguments = requireArguments()
32 | viewModel.getRingtoneLiveData(
33 | categoryType = arguments.getInt(EXTRA_CATEGORY_TYPE, -1).let { type ->
34 | UltimateRingtonePicker.RingtoneCategoryType.entries.first { it.ordinal == type }
35 | },
36 | categoryId = arguments.getLong(EXTRA_CATEGORY_ID)
37 | ).observe(viewLifecycleOwner) { ringtones ->
38 | binding.urpProgress.hide()
39 | if (ringtones.isNotEmpty()) {
40 | itemAdapter.setNewList(ringtones.map { ringtone ->
41 | VisibleRingtone(
42 | ringtone = ringtone,
43 | ringtoneType = VisibleRingtone.RINGTONE_TYPE_CUSTOM
44 | )
45 | })
46 | fastAdapter.getSelectExtension()
47 | .withSavedInstanceState(savedInstanceState, KEY_SELECTION)
48 | } else {
49 | itemAdapter.setNewList(listOf(VisibleEmptyView()))
50 | }
51 | }
52 | }
53 |
54 | override fun onResume() {
55 | super.onResume()
56 | myself = this
57 | }
58 |
59 | override fun onSaveInstanceState(outState: Bundle) {
60 | super.onSaveInstanceState(outState)
61 | ringtoneFastAdapter?.getSelectExtension()?.saveInstanceState(outState, KEY_SELECTION)
62 | }
63 |
64 | override fun onSelect() {
65 | val ringtones =
66 | ringtoneFastAdapter?.getSelectExtension()?.selectedItems?.mapNotNull { (it as? VisibleRingtone)?.ringtone }
67 | if (ringtones?.isNotEmpty() == true) {
68 | if (viewModel.settings.systemRingtonePicker == null) {
69 | viewModel.stopPlaying()
70 | viewModel.onFinalSelection(ringtones)
71 | } else {
72 | viewModel.onDeviceSelection(ringtones)
73 | findNavController().popBackStack(R.id.urp_dest_system, false)
74 | }
75 | } else {
76 | viewModel.stopPlaying()
77 | }
78 | }
79 |
80 | override fun onBack(): Boolean {
81 | viewModel.stopPlaying()
82 | // If we pop back to DeviceRingtoneFragment, the scroll position is lost.
83 | return findNavController().popBackStack()
84 | }
85 |
86 | override fun onPause() {
87 | super.onPause()
88 | myself = null
89 | }
90 |
91 | companion object {
92 | private const val KEY_SELECTION = "selection"
93 |
94 | /**
95 | * I can't find a way to get current fragment in ViewPager so I use this way.
96 | */
97 | internal var myself: RingtoneFragment? = null
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/VisibleAddCustom.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import com.mikepenz.fastadapter.binding.AbstractBindingItem
7 | import xyz.aprildown.ultimateringtonepicker.R
8 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpRingtoneBinding
9 |
10 | internal class VisibleAddCustom : AbstractBindingItem() {
11 |
12 | override val type: Int = R.id.urp_item_add_custom
13 | override var identifier: Long = 1
14 | override var isSelectable: Boolean = false
15 |
16 | override fun bindView(binding: UrpRingtoneBinding, payloads: List) {
17 | super.bindView(binding, payloads)
18 | binding.run {
19 | urpImageRingtone.setImageResource(R.drawable.urp_add_custom)
20 | urpTextRingtoneName.setText(R.string.urp_add_new_sound)
21 | urpImageSelected.visibility = View.GONE
22 | }
23 | }
24 |
25 | override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?): UrpRingtoneBinding {
26 | return UrpRingtoneBinding.inflate(inflater, parent, false)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/VisibleCategory.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import com.mikepenz.fastadapter.binding.AbstractBindingItem
7 | import xyz.aprildown.ultimateringtonepicker.R
8 | import xyz.aprildown.ultimateringtonepicker.data.Category
9 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpTwoLinesBinding
10 |
11 | internal class VisibleCategory(
12 | val category: Category,
13 | val primaryText: String,
14 | val secondaryText: String
15 | ) : AbstractBindingItem() {
16 |
17 | override val type: Int = R.id.urp_item_two_lines
18 | override var isSelectable: Boolean = false
19 |
20 | override fun bindView(binding: UrpTwoLinesBinding, payloads: List) {
21 | super.bindView(binding, payloads)
22 | binding.run {
23 | urpTextCategoryName.text = primaryText
24 | if (secondaryText.isNotBlank()) {
25 | urpTextCategoryContent.visibility = View.VISIBLE
26 | urpTextCategoryContent.text = secondaryText
27 | } else {
28 | urpTextCategoryContent.visibility = View.GONE
29 | }
30 | }
31 | }
32 |
33 | override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?): UrpTwoLinesBinding {
34 | return UrpTwoLinesBinding.inflate(inflater, parent, false)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/VisibleEmptyView.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import com.mikepenz.fastadapter.binding.AbstractBindingItem
6 | import xyz.aprildown.ultimateringtonepicker.R
7 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpEmptyBinding
8 |
9 | internal class VisibleEmptyView : AbstractBindingItem() {
10 |
11 | override val type: Int = R.layout.urp_empty
12 | override var isSelectable: Boolean = false
13 |
14 | override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?): UrpEmptyBinding {
15 | return UrpEmptyBinding.inflate(inflater, parent, false)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/VisibleRingtone.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import androidx.core.view.isVisible
6 | import com.mikepenz.fastadapter.binding.AbstractBindingItem
7 | import xyz.aprildown.ultimateringtonepicker.R
8 | import xyz.aprildown.ultimateringtonepicker.data.Ringtone
9 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpRingtoneBinding
10 | import xyz.aprildown.ultimateringtonepicker.startDrawableAnimation
11 |
12 | internal class VisibleRingtone(
13 | val ringtone: Ringtone,
14 | val ringtoneType: Int
15 | ) : AbstractBindingItem() {
16 |
17 | var isPlaying: Boolean = false
18 |
19 | override val type: Int = R.id.urp_item_ringtone
20 | override var identifier: Long = ringtone.hashCode().toLong()
21 | override var isSelectable: Boolean = true
22 |
23 | override fun bindView(binding: UrpRingtoneBinding, payloads: List) {
24 | super.bindView(binding, payloads)
25 | binding.run {
26 | urpImageRingtone.setImageResource(
27 | when {
28 | !ringtone.isValid -> R.drawable.urp_broken_ringtone
29 | ringtoneType == RINGTONE_TYPE_CUSTOM -> R.drawable.urp_custom_music
30 | ringtoneType == RINGTONE_TYPE_SILENT -> R.drawable.urp_ringtone_silent
31 | isPlaying -> R.drawable.urp_ringtone_active
32 | else -> R.drawable.urp_ringtone_normal
33 | }
34 | )
35 | // Only works on R.drawable.urp_ringtone_active
36 | urpImageRingtone.startDrawableAnimation()
37 |
38 | urpTextRingtoneName.text = ringtone.title
39 |
40 | urpImageSelected.isVisible = isSelected
41 | }
42 | }
43 |
44 | override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?): UrpRingtoneBinding {
45 | return UrpRingtoneBinding.inflate(inflater, parent, false)
46 | }
47 |
48 | companion object {
49 | const val RINGTONE_TYPE_CUSTOM = 0
50 | const val RINGTONE_TYPE_SILENT = 1
51 | const val RINGTONE_TYPE_SYSTEM = 2
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/library/src/main/java/xyz/aprildown/ultimateringtonepicker/ui/VisibleSection.kt:
--------------------------------------------------------------------------------
1 | package xyz.aprildown.ultimateringtonepicker.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import com.mikepenz.fastadapter.binding.AbstractBindingItem
6 | import xyz.aprildown.ultimateringtonepicker.R
7 | import xyz.aprildown.ultimateringtonepicker.databinding.UrpSectionBinding
8 |
9 | internal class VisibleSection(val title: String) : AbstractBindingItem() {
10 |
11 | override val type: Int = R.id.urp_item_section
12 | override var identifier: Long = title.hashCode().toLong()
13 | override var isSelectable: Boolean = false
14 |
15 | override fun bindView(binding: UrpSectionBinding, payloads: List) {
16 | super.bindView(binding, payloads)
17 | binding.urpTextSection.text = title
18 | }
19 |
20 | override fun createBinding(inflater: LayoutInflater, parent: ViewGroup?): UrpSectionBinding {
21 | return UrpSectionBinding.inflate(inflater, parent, false)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/library/src/main/res/anim-v22/urp_ringtone_active_animation_interpolator.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/library/src/main/res/animator-v22/urp_ringtone_active_outlines_0_animation.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/library/src/main/res/animator-v22/urp_ringtone_active_outlines_1_animation.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/library/src/main/res/animator-v22/urp_ringtone_active_outlines_2_animation.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable-v22/urp_ringtone_active_animated.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
10 |
13 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_add_custom.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_broken_ringtone.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_custom_music.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_ringtone_active_static.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
13 |
17 |
21 |
25 |
26 |
31 |
35 |
39 |
43 |
44 |
45 |
46 |
47 |
51 |
55 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_ringtone_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_ringtone_normal.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_ringtone_selected.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/library/src/main/res/drawable/urp_ringtone_silent.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_activity_ringtone_picker.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
12 |
13 |
14 |
15 |
19 |
20 |
31 |
32 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_dialog.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
13 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_empty.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_fragment_device_ringtone.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_horizontal_divider.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_recycler_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
15 |
16 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_ringtone.xml:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
25 |
26 |
33 |
34 |
38 |
39 |
45 |
46 |
52 |
53 |
60 |
61 |
71 |
72 |
73 |
74 |
86 |
87 |
88 |
89 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_section.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/library/src/main/res/layout/urp_two_lines.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
19 |
20 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/library/src/main/res/navigation/urp_nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
12 |
13 |
18 |
19 |
23 |
24 |
--------------------------------------------------------------------------------
/library/src/main/res/values-de/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Hinzufügen
4 | Lautlos
5 | Unbekannt
6 | Meine Töne
7 | Gerätetöne
8 | Entfernen
9 | Standard
10 | Klingelton
11 | Benachrichtigung
12 | Alarm
13 | Leeren
14 | Für die Auswahl eines benutzerdefinierten Klingeltons ist eine externe Speicherberechtigung erforderlich.
15 | Künstlerin
16 | Album
17 | Ordner
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-es/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Añadir nuevo
4 | Silencio
5 | Desconocido
6 | Tus sonidos
7 | Sonidos del dispositivo
8 | Quitar
9 | Defecto
10 | Tono de llamada
11 | Notificación
12 | Alarma
13 | Vacío
14 | Se necesita un permiso de almacenamiento externo para elegir un tono de llamada personalizado.
15 | Artista
16 | Álbum
17 | Carpeta
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-fr/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Ajouter
4 | Silencieux
5 | Inconnue
6 | Vos sons
7 | Sons de l\'appareil
8 | Supprimer
9 | Défaut
10 | Sonnerie
11 | Notification
12 | Alarme
13 | Vide
14 | Une autorisation de stockage externe est nécessaire pour choisir une sonnerie personnalisée.
15 | Artiste
16 | Album
17 | Dossier
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-hi/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | नया जोड़ें
4 | मौन
5 | अज्ञात
6 | आपकी ध्वनियां
7 | डिवाइस की ध्वनियां
8 | निकालें
9 | चूक
10 | रिंगटोन
11 | अधिसूचना
12 | अलार्म
13 | खाली
14 | कस्टम रिंगटोन लेने के लिए एक्सटर्नल स्टोरेज परमिशन की जरूरत होती है।
15 | कलाकार
16 | एल्बम
17 | फोल्डर
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-ja/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 新しく追加
4 | マナーモード
5 | 不明
6 | マイサウンド
7 | 端末のサウンド
8 | 削除
9 | デフォルト
10 | 着信音
11 | 通知
12 | アラーム
13 | 空の
14 | カスタム着信音を選択するには、外部ストレージのアクセス許可が必要です。
15 | アーティスト
16 | アルバム
17 | フォルダ
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #14FFFFFF
4 |
5 |
--------------------------------------------------------------------------------
/library/src/main/res/values-nl/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Nieuw toevoegen
4 | Stil
5 | Onbekend
6 | Jouw geluiden
7 | Apparaatgeluiden
8 | Verwijderen
9 | Standaard
10 | Beltoon
11 | Meldings
12 | Alarm
13 | Leeg
14 | Externe opslagtoestemming is nodig om een aangepaste beltoon te kiezen.
15 | Kunstenaar
16 | Album
17 | Map
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-pl/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dodaj nowy
4 | Cichy
5 | Nieznany
6 | Twoje dźwięki
7 | Dźwięki urządzenia
8 | Usuń
9 | Domyślna
10 | Dzwonek
11 | Powiadomienie
12 | Alarm
13 | Pusty
14 | Aby wybrać niestandardowy dzwonek, potrzebne jest zezwolenie na przechowywanie zewnętrzne.
15 | Artista
16 | Álbum
17 | Folder
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-v22/drawable.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | - @drawable/urp_ringtone_active_animated
4 |
--------------------------------------------------------------------------------
/library/src/main/res/values-zh-rCN/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 新增
4 | 静音
5 | 未知
6 | 您的提示音
7 | 设备提示音
8 | 移除
9 | 默认提示音
10 | 铃声
11 | 通知
12 | 闹钟
13 | 无内容
14 | 需要读取储存的权限来选择自定义铃声文件。
15 | 艺术家
16 | 专辑
17 | 文件夹
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-zh-rHK/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 新增
4 | 靜音
5 | 未知
6 | 您的提示音
7 | 設備提示音
8 | 移除
9 | 默認提示音
10 | 鈴聲
11 | 通知
12 | 鬧鐘
13 | 無內容
14 | 需要讀取儲存的權限來選擇自定義鈴聲文件。
15 | 藝術家
16 | 專輯
17 | 文件夾
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values-zh-rTW/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 新增
4 | 靜音
5 | 不明
6 | 你的音效
7 | 裝置音效
8 | 移除
9 | 預設提示音
10 | 鈴聲
11 | 通知
12 | 鬧鐘
13 | 無內容
14 | 需要讀取儲存的許可權來選擇自定義鈴聲檔案。
15 | 藝術家
16 | 專輯
17 | 資料夾
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #14000000
4 |
5 |
--------------------------------------------------------------------------------
/library/src/main/res/values/drawable.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | - @drawable/urp_ringtone_active_static
4 |
--------------------------------------------------------------------------------
/library/src/main/res/values/ids.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/library/src/main/res/values/public.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/library/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Add new
4 | Silent
5 | Unknown
6 | Your sounds
7 | Device sounds
8 | Remove
9 | Default
10 | Ringtone
11 | Notification
12 | Alarm
13 | Empty
14 | External Storage Permission is needed to pick custom ringtone.
15 | Artist
16 | Album
17 | Folder
18 |
19 |
--------------------------------------------------------------------------------
/library/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'UltimateRingtonePicker'
2 |
3 | include ':library'
4 | include ':app'
5 |
--------------------------------------------------------------------------------