From b07679518cdf76662cfb8cebfd6102bfdebfb0c0 Mon Sep 17 00:00:00 2001 From: Carlos Puchol Date: Sat, 9 Dec 2017 01:57:21 -0800 Subject: [PATCH] Final bits for 3.0.0 release! Squashed commit of the following: commit bd11d72468e5ab11b3f2afd4b8c32e4364edd4d7 Author: Chirag Maheshwari Date: Wed Dec 6 05:17:18 2017 +0530 Fixes Audio crash in #284 (#285) * update dependency versions * resolve app crash on loosing net connectivity while playing audio * resolve controls auto hiding after 3 secs and minor playback bugs * add build-tools ver 27.0.1 to .travis.yml * disable aapt2 due to failing unit tests commit f96af3bf72bdf359880163622c63207fb43dab14 Author: Chirag Date: Sat Dec 2 00:53:32 2017 +0530 remove duplicate permissions from manifest commit 7577b17482552689e4c75dc348db8e2faf67985b Author: Carlos Puchol Date: Tue Oct 17 13:58:08 2017 -0700 bump to 2.9.4 for release commit 25149defde861359845cb8e5563e5478f7b95788 Author: Chirag Maheshwari Date: Wed Oct 18 02:22:05 2017 +0530 Use FileProvider to get file URI for API 24+ (#280) commit 041f4f7e02a59bd78efcc4be8a47ab710a986a97 Author: Chirag Maheshwari Date: Wed Oct 18 02:15:14 2017 +0530 reindented code and added .editorconfig (#279) commit b9b59a6bc2c52830bd68834f87d760047cb25262 Author: Carlos Puchol Date: Mon Oct 16 23:54:33 2017 -0700 beta channel release 2.9.3 commit 5eb56499771ffcd0f0da75488c91fd6306445686 Merge: 7688cb6 b468519 Author: megabitdragon Date: Mon Oct 16 21:29:26 2017 -0500 Merge pull request #278 from csoni111/beta Made CastOptionsProvider class public commit b468519e81f9392ad9647574f4695d94ce291861 Author: Chirag Date: Tue Oct 17 00:15:40 2017 +0530 Made CastOptionsProvider class public commit 7688cb6dfdcfb6b81a0a035d517998b102930994 Author: Carlos Puchol Date: Mon Oct 16 03:19:05 2017 -0700 2.9.2 release in beta channel commit d42a1a6d748190430c46c799b8a8e84b9b8b5d36 Author: Ajul Raj Date: Mon Oct 16 15:14:52 2017 +0530 Added code to delete file if it already exists. (#277) * Fixes app crash if audioFile is Null (#260) * fix app crash if audioFile is Null * adds api-19 to travis.yml * initial cast files * add app icon * updates logo and css for cast receiver * Added code to delete file if it already exists. * Update Downloader.java * Update Downloader.java commit 03523b07166a6b87a686293352b6a2a0cd7e5da5 Merge: 4e515c8 8b8764d Author: Chirag Date: Mon Oct 16 14:55:31 2017 +0530 Merge branch 'master' into beta commit 4e515c8170e25a7e03280a95827a90707337aa8c Merge: bd31a3b 4435b2a Author: Chirag Date: Tue Sep 5 10:13:10 2017 +0530 Merge branch 'beta' of github.com:amahi/android into beta commit 4435b2aad6ccf86b60af5c0d03daa42c2a819f4b Author: Chirag Maheshwari Date: Tue Aug 29 15:40:54 2017 +0530 New apis for upload & delete and auto upload feature (#272) * Upload and Delete file api and implementation (#242) * feat: adds delete file functionality * fix: change api route for file delete * adds basic file upload functionality * fix image null pointer error and switch to query params * adds file upload progress event * adds permission handler before uploading files * adds check for duplicate file upload * fix file not found bug * adds feature to upload video files too * refactor code * adds bottom sheet for displaying upload options * adds upload click listeners and callbacks in ServerFilesActivity * implements camera upload functionality * minor fixes and code clean up * removes upload icon from title bar * fix: forwarding to the desired action after grating permissions * feature to add custom hda servers from json * add debug file * resolve crash on uploading files from movies share * Fixed the landscape bottomSheet hidden by default bug. (#257) * Fixed the landscape bottomSheet hidden by default bug. * Adds android-19 to the travis.yml. * Auto upload feature (#253) * adds auto upload settings to preference screen * adds NEW_PICTURE broadcast receiver * adds upload service * adds upload settings preference fragment * adds hda and share list fetching to upload settings * adds sqlite db for storing image paths * adds custom upload queue * implements upload manager and server connection in background service * fix the UI stuck on file uploads * fix multiple files uploading at the same time * code refactoring * adds upload only on wifi preference option * adds api-19 to travis.yml * adds Job for tracking changes in images content uri * adds job scheduling to the application class * adds net connnectivity job * adds permission checker to auto upload settings * resolve TransactionTooLargeException on android 7 commit 37c8ec2cc484f83636627e8b8cdd9800fe1803b3 Author: Chirag Maheshwari Date: Tue Aug 29 09:01:06 2017 +0530 Adds Chromecast functionality (#273) * Chromecast support (#266) * resolve TransactionTooLargeException on android 7 * adds chromecast support * adds cast session manager and remote media to native player * adds ExpandedController for cast * resolve blank video activity on stopping cast * adds mini controller * adds cast notifications and change app id * resolve crash on opening cast activity through notification * adds cast support for audio files * start casting only after metadata is fetched * adds fixed image audio cast * shift chromecast app_id in api.properties * remove custom audio-play image * adds cast options to image viewer (#269) commit ee3ba01b98b4062126eb25b819657028c66c71e7 Author: Chirag Maheshwari Date: Tue Aug 22 15:03:19 2017 +0530 resolve TransactionTooLargeException on android 7 (#265) commit a15b3c711002b43bef507e6a61be9fe5f5176f9c Author: Kumar Shashwat Date: Tue Aug 22 15:02:35 2017 +0530 Displays album art as a thumbnail. (#267) commit bd31a3b67593f5b53e9cc9bef4afd8c00cb8d199 Author: Chirag Maheshwari Date: Sun Aug 20 00:44:03 2017 +0530 resolve TransactionTooLargeException on android 7 commit 0a84fc886e6216ec9975140f260fc895d4a0d44a Author: Kumar Shashwat Date: Sat Aug 19 21:32:44 2017 +0530 Fixed auto start of song on audio focus gain (#263) change audio focus to loss only if song is playing commit 8aefb6fc6d50c22ed89067065fa7168997ed3b92 Author: Kumar Shashwat Date: Tue Aug 15 01:10:21 2017 +0530 Added d-pad functionalities to the ATV (#259) * Added functionality to the remote's play/pause button Added forward and rewind from the d-pad buttons. Added play/pause d pad support to the audio player. * Added an exit pop-up when exitting the video player. * Code refractoring * Added android-19 in travis.yml commit 4848139151046feb0d10adfa1f385b2c1c0f4185 Author: Kumar Shashwat Date: Tue Jul 25 00:01:15 2017 +0530 added meta-data to movies and removed the greying out in the video player. (#252) Removed the skipNext and skipPrevious buttons from the ATV. Added hand controller funtions. commit 6ed96c92b4cdd42dd065e96fbdfc918005015e45 Author: Carlos Puchol Date: Sat Jul 22 19:31:26 2017 -0700 beta version 2.9.0 released commit defc83eaa1b8d0ba1b1fa58a644d361dcfe43bdf Author: Kumar Shashwat Date: Sat Jul 22 09:43:19 2017 +0530 Improved the fast forward and rewind function in the Android TV (#247) Moved the fragments to the FragmentBuilders to util.Fragments Fixed some performace issues and added subtitles support using the libvlc. commit 4dce959ca37e6a8bbef7ef2b885422dd654fbcef Author: Carlos Puchol Date: Wed Jul 19 04:18:12 2017 -0700 bump version for beta release commit 7ead34507f1146507177dd81ee5ece3ccececdb4 Author: Kumar Shashwat Date: Wed Jul 19 05:14:28 2017 +0530 Improved the strings for both phone and TV. (#246) * Improved the strings for both phone and TV. * updated intro activity strings updated intro activity strings commit 25e915b4de5414673bdc435b5bf2d31bb9c7f28e Merge: 7e1822a d06b680 Author: Carlos Puchol Date: Sun Jul 16 14:28:50 2017 -0700 Merge pull request #245 from octacode/beta commit d06b680ae28541aaa3fdff2b13ca255ef2e84ae8 Author: octacode Date: Mon Jul 17 00:58:36 2017 +0530 Added the lower row in the TV player. commit 8a34bf74bee71067f5c28f84941070e11905f1da Author: octacode Date: Sun Jul 16 23:33:28 2017 +0530 Added seekbar to the .avi and other files. Improved indentation. Implemented the skipNExt and skipPrevious in tv player and added a listener so that the next video gets played once the first is over. commit c94ae8baa5d54907123e2bac2d13f249f8237bc3 Author: octacode Date: Sun Jul 16 20:07:26 2017 +0530 Removed the videoView and replacced it with surfaceView with the required coding tweaks. Added the vlc basic controls layout for the Android TV. Implemented the rewind and forward button in tvplayer. Added seek bar and some other improvements. Reformatted the code. Removed lint issues. Removed the depriciated overlay fragment. Removed the .avi crash. commit 7e1822acfb912f41c2e2bca9e9271af75b17e315 Author: Carlos Puchol Date: Sun Jul 16 02:50:22 2017 -0700 bump the version after beta release with all audio improvements! commit 728e2f6062097386211539b852510cd11de7830b Author: Kumar Shashwat Date: Fri Jul 14 14:24:29 2017 +0530 Tried injecting the serverClient in the tv audio player (#244) Added background updates for the Tv Audio player Added other rows in the Android TV Implemented play/pause fo ATV audio player Added the progress bar in the Atv music player. Added seek bar in the video fragment. Added functionality to the bottom row. Fixed the audio player bug. Improved the header. Implemented the rewind, fast-forwar, skip-next and skip-previous buttons in the music player. Plays next song once the current song ends. Added some improvements to the Audio and video player of ATV. Updated meta_data for audio player. commit a8ab614ea943d725430c43d036f5e7403fee4116 Author: Carlos Puchol Date: Tue Jul 11 04:20:55 2017 -0700 bump version to 2.6.0 for beta release commit 04593611709e41a5a081965aa7b9849b3541f809 Author: Kumar Shashwat Date: Tue Jul 11 16:16:03 2017 +0530 Fixed the server select bug by removing the back option. (#241) implemented an algorithm to sort the header items. Fixed the spinner bug. Removed the back button from connection selector. commit 0d957573711f19b79dfbcb94b6e94a6646edb072 Author: Kumar Shashwat Date: Mon Jul 10 04:03:43 2017 +0530 Implemented Video player for ATV, improved padding for the main screen, displays meta-data and artwork in the folder, removed background image updates etc. (#238) * Now the meta data and the artwork gets displayed inside a folder also. Removed the ServerTvPreenter Removed background image updates and changed the tile color to transparent. * Improved the background of the tiles * Added the layout for the TV Video player. * Implemented vnative ideo player for Android TV. * Improved padding so that the icons don't touch the border of the listRowItem. * Fixed the videoplayer on launch bug. * Replaced the shadow logos with tv banner Removed title text for video metadata. * Added the shadowless image. commit 94facf8f7370e9c8ff8ac63b821a25619827fc1c Author: Carlos Puchol Date: Sat Jul 8 14:13:53 2017 -0700 Squashed commit of the following: commit 6b95c6ef54151bd3abf6f049c0b19bb376b4f249 Merge: cf81d2f 28bc9df Author: Carlos Puchol Date: Sat Jul 8 14:03:34 2017 -0700 Merge branch 'meta_data' of https://github.com/octacode/android into octacode-meta_data commit 28bc9df3554230d0af5dd4c264270eaf57d4f671 Author: octacode Date: Fri Jul 7 19:29:17 2017 +0530 Displays album art on the Main screen if present. fixed the audio player crash. Changed the back ground color of tiles to white commit 8a2e4bd6ce3b2cf243b6e5eac5a69ef349e32fa3 Author: octacode Date: Fri Jul 7 18:16:47 2017 +0530 Improved the icons. commit f4600660476b5e246009d90dc9116cb81376ec5c Author: octacode Date: Fri Jul 7 15:07:20 2017 +0530 Moved the settings to the end Added gear icon for the settings and text for others commit 35ccb24ff9d7ea9c4cede0c7bad26ada66b5706a Author: octacode Date: Thu Jul 6 23:30:29 2017 +0530 Implemented the splash activity. commit 4475c175db4dff2415a2f68f6a44e24cf1a04489 Author: octacode Date: Thu Jul 6 22:53:17 2017 +0530 Integrated the Introduction activity in the app. commit 02f8ce326f5e5095138ef3fd91e7be35b0178be4 Author: octacode Date: Thu Jul 6 19:47:08 2017 +0530 Added info_background update to the selected icon. commit 762ce297a46135402841088248eeac10222fcdb2 Author: octacode Date: Sun Jul 2 10:15:06 2017 +0530 Fixes #236 Displayed metadata framework in the movie share if present Changed the size of the movie list item commit cf81d2f20fa91f68db2ae2c73b355ebd9d58ae0d Author: Carlos Puchol Date: Sat Jul 8 13:52:25 2017 -0700 bring back the fake api for the tests and make a note of it commit 2558bfa6190e988cf319f48d034c01a8234d7d20 Author: Carlos Puchol Date: Wed Jun 28 14:31:27 2017 -0700 bump version for submission commit 4e71152e5e23f6d161169f33f41d321d312699ad Author: Carlos Puchol Date: Wed Jun 28 14:19:56 2017 -0700 don't even check for presence commit fba0be4e0e60335e657f96beeb84d34d69efb27c Author: Carlos Puchol Date: Wed Jun 28 14:12:44 2017 -0700 update banner, do not build if the proper api file is there commit de1ee8f63ea8a243acd412a81dac419fd2bf12f0 Author: Kumar Shashwat Date: Wed Jun 28 04:32:29 2017 +0530 Made the MainTVActivity as the leanback_launcher (#232) * Made the MainTvActivity as the leanback_launcher. * Added mime support to the folders inside. Refined the ServerTvFileFragment.java Added background update manager to the serverTvFilesActivity Moved the presenter out of the ServerTVFragment Added background updates at Image mime on the MainTvFragment * Added a temporary banner. commit 52537d8f74d015ba61747df8843d2e5d1dd6f939 Author: Carlos Puchol Date: Tue Jun 27 02:36:42 2017 -0700 beta version 2.5.1 commit dded2ed52cfc0e158a0fbfbaa493c9672e1ce6b1 Merge: 6a58c9d 53bec14 Author: Carlos Puchol Date: Fri Jun 23 14:42:23 2017 -0700 Merge branch 'master' into beta commit 6a58c9d5adb38acdcbb7895f27404489cb0994e4 Author: Kumar Shashwat Date: Sat Jun 24 00:27:01 2017 +0530 Merged the android-tv to beta (#231) * Added a redirect activity. (#220) Login, Main and Introduction activities for Android TV * Removed hardcoded strings and improved code readability, indentation etc (#221) * Improved structuring of TV app code, added settings layout, fixed the filtered servers bug and implemented sign out. (#222) * Implemented the MainTvFragment. (#223) * Integrated Shares as a HeaderItem and its content as ListRowItem. Removed Apps(Permanently) and settings(temporary) Moved the isATV() and Preferences code to the util/Preference.java Implmented ViewStubs. Fixed the ServerFileActivity crash in the android app. Moved the isATV to a different class CheckAtv in the util package Added the settings activity in the MainTVFragment Implemented Sign-out, connection and sign-out preferences. Removed fragment callback to improve structure. Added network calls and made the TV independent of external fragment callbacks. Added settings and separator. Implemented the sort algorithm in the MainTVFragment server select under construction. Reformatted the code and improved the indentation, spacing etc * Reformatted code and improved indentation. * not complete yet. * Implemented the Server selection setting. * Improved reformating and indentation. * Handles the fileOpening at the MainTVActivity (#226) Fixed the server selection bug. * Added browsing functionality to the TV app and merged beta into android-tv (#227) * Video player updates (#216) * adds subtitle view in video player * adds buffer percentage and error toast * adds subtitle fetch and display * resolves buggy video player controls * Updates mime type support for subs and disable them (#224) (cherry picked from commit a3bfc9cf896f469d0d1d98fad398f3cd73e87d15) * Feature swipe gestures (#225) * Adds swipe gestures * adds GestureDetector * adds functions to update volume, seek, brightness * minor fixes for smooth gestures * size of the progress bar and minor looks changed * resolves minor issues - resolves video controls opening at the end of swipe gestures - move seek text to the bottom of the video * Added the ServerFileTvActivity * User can successfully browse the content of the shares now. * Improved code indentation and reformatting. * Fixed the crash in the while opening a file during browsing. commit 5b567ec685f91a370407ca86c9a09678022f8254 Author: Chirag Maheshwari Date: Fri Jun 23 05:13:54 2017 +0530 Feature native video player (#229) * relasing v2.4.0 * adds Native Video Player * minor bugs resolved * tweak mime types (and add a commented-out toast for testing the native player) * fix: media controls hiding behind soft nav buttons commit 6926d4fa93b3f7ca43cf57cc3c700230db9eb95a Author: Chirag Maheshwari Date: Tue Jun 20 04:31:27 2017 +0530 Feature swipe gestures (#225) * Adds swipe gestures * adds GestureDetector * adds functions to update volume, seek, brightness * minor fixes for smooth gestures * size of the progress bar and minor looks changed * resolves minor issues - resolves video controls opening at the end of swipe gestures - move seek text to the bottom of the video commit a1d077eb5bb8870ce770f81b3a8b4d9bd7ab654e Author: Chirag Maheshwari Date: Thu Jun 15 00:20:35 2017 +0530 Updates mime type support for subs and disable them (#224) (cherry picked from commit a3bfc9cf896f469d0d1d98fad398f3cd73e87d15) commit 658bc79fca73f8bbfe4310c8c575a9d0fb87b962 Author: Chirag Maheshwari Date: Tue Jun 6 04:53:29 2017 +0530 Video player updates (#216) * adds subtitle view in video player * adds buffer percentage and error toast * adds subtitle fetch and display * resolves buggy video player controls --- .editorconfig | 17 + .gitignore | 1 + .travis.yml | 2 +- DEBUG.md | 24 + api.properties.sample | 8 + build.gradle | 66 +- fakeApi.properties | 2 +- fakeSigning.properties | 1 - gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 4 +- .../activity/AuthenticationActivityTest.java | 8 +- src/main/AndroidManifest.xml | 111 +- src/main/assets/.gitignore | 1 + .../org/amahi/anywhere/AmahiApplication.java | 24 + .../java/org/amahi/anywhere/AmahiModule.java | 66 +- .../amahi/anywhere/account/AmahiAccount.java | 13 +- .../anywhere/account/AmahiAuthenticator.java | 151 +- .../account/AmahiAuthenticatorService.java | 23 +- .../activity/AuthenticationActivity.java | 440 +++--- .../activity/ExpandedControlsActivity.java | 35 + .../activity/IntroductionActivity.java | 63 + .../activity/NativeVideoActivity.java | 377 +++++ .../anywhere/activity/NavigationActivity.java | 579 ++++---- .../anywhere/activity/ServerAppActivity.java | 375 +++-- .../activity/ServerFileAudioActivity.java | 1081 ++++++++------ .../activity/ServerFileImageActivity.java | 486 +++--- .../activity/ServerFileVideoActivity.java | 1075 +++++++------- .../activity/ServerFileWebActivity.java | 191 ++- .../activity/ServerFilesActivity.java | 728 ++++++--- .../anywhere/activity/SettingsActivity.java | 99 +- .../anywhere/activity/SplashActivity.java | 35 + .../anywhere/activity/WebViewActivity.java | 8 +- .../adapter/FilesFilterBaseAdapter.java | 116 +- .../adapter/NavigationDrawerAdapter.java | 46 +- .../anywhere/adapter/ServerAppsAdapter.java | 142 +- .../anywhere/adapter/ServerFilesAdapter.java | 124 +- .../adapter/ServerFilesImagePagerAdapter.java | 41 +- .../adapter/ServerFilesMetadataAdapter.java | 240 ++- .../anywhere/adapter/ServerSharesAdapter.java | 110 +- .../anywhere/adapter/ServersAdapter.java | 123 +- .../adapter/UploadOptionsAdapter.java | 90 ++ .../bus/AudioMetadataRetrievedEvent.java | 34 +- .../bus/FileMetadataRetrievedEvent.java | 9 +- .../anywhere/bus/ServerConnectedEvent.java | 11 + .../anywhere/bus/ServerFileDeleteEvent.java | 32 + .../bus/ServerFileUploadCompleteEvent.java | 38 + .../bus/ServerFileUploadProgressEvent.java | 38 + .../amahi/anywhere/bus/UploadClickEvent.java | 35 + .../bus/UploadSettingsOpeningEvent.java | 23 + .../org/amahi/anywhere/db/UploadQueueDb.java | 41 + .../anywhere/db/UploadQueueDbHelper.java | 94 ++ .../fragment/GooglePlaySearchFragment.java | 84 +- .../anywhere/fragment/NavigationFragment.java | 853 ++++++----- .../anywhere/fragment/ServerAppsFragment.java | 296 ++-- .../ServerFileDownloadingFragment.java | 139 +- .../fragment/ServerFileImageFragment.java | 160 +- .../fragment/ServerFilesFragment.java | 1314 +++++++++-------- .../fragment/ServerSharesFragment.java | 235 ++- .../anywhere/fragment/SettingsFragment.java | 468 +++--- .../anywhere/fragment/UploadBottomSheet.java | 102 ++ .../fragment/UploadSettingsFragment.java | 418 ++++++ .../anywhere/job/NetConnectivityJob.java | 85 ++ .../amahi/anywhere/job/PhotosContentJob.java | 125 ++ .../org/amahi/anywhere/model/UploadFile.java | 38 + .../amahi/anywhere/model/UploadOption.java | 58 + .../anywhere/receiver/AudioReceiver.java | 117 +- .../anywhere/receiver/CameraReceiver.java | 39 + .../anywhere/receiver/NetworkReceiver.java | 63 +- .../org/amahi/anywhere/server/ApiAdapter.java | 35 +- .../amahi/anywhere/server/ApiConnection.java | 9 +- .../server/ApiConnectionDetector.java | 74 +- .../org/amahi/anywhere/server/ApiHeaders.java | 62 +- .../org/amahi/anywhere/server/ApiModule.java | 65 +- .../amahi/anywhere/server/api/AmahiApi.java | 23 +- .../amahi/anywhere/server/api/ProxyApi.java | 9 +- .../amahi/anywhere/server/api/ServerApi.java | 61 +- .../anywhere/server/client/AmahiClient.java | 41 +- .../anywhere/server/client/ServerClient.java | 368 +++-- .../anywhere/server/model/Authentication.java | 13 +- .../amahi/anywhere/server/model/Server.java | 103 +- .../anywhere/server/model/ServerApp.java | 87 +- .../anywhere/server/model/ServerFile.java | 255 ++-- .../server/model/ServerFileMetadata.java | 72 +- .../anywhere/server/model/ServerRoute.java | 31 +- .../anywhere/server/model/ServerShare.java | 126 +- .../response/AuthenticationResponse.java | 33 +- .../server/response/ServerAppsResponse.java | 25 +- .../response/ServerFileDeleteResponse.java | 48 + .../response/ServerFileUploadResponse.java | 55 + .../server/response/ServerFilesResponse.java | 56 +- .../server/response/ServerRouteResponse.java | 3 +- .../server/response/ServerSharesResponse.java | 25 +- .../server/response/ServersResponse.java | 63 +- .../amahi/anywhere/service/AudioService.java | 914 ++++++------ .../amahi/anywhere/service/UploadService.java | 313 ++++ .../amahi/anywhere/service/VideoService.java | 248 ++-- .../task/AudioMetadataRetrievingTask.java | 22 +- .../task/FileMetadataRetrievingTask.java | 13 +- .../anywhere/tv/activity/IntroActivity.java | 34 + .../anywhere/tv/activity/MainTVActivity.java | 74 + .../tv/activity/ServerFileTvActivity.java | 93 ++ .../tv/activity/SettingsActivity.java | 75 + .../tv/activity/TVWebViewActivity.java | 74 + .../tv/activity/TvPlaybackAudioActivity.java | 81 + .../tv/activity/TvPlaybackVideoActivity.java | 141 ++ .../tv/fragment/ConnectionFragment.java | 190 +++ .../anywhere/tv/fragment/IntroFragment.java | 227 +++ .../anywhere/tv/fragment/MainTVFragment.java | 271 ++++ .../tv/fragment/ServerFileTvFragment.java | 194 +++ .../tv/fragment/ServerSelectFragment.java | 192 +++ .../anywhere/tv/fragment/SignOutFragment.java | 146 ++ .../anywhere/tv/fragment/ThemeFragment.java | 187 +++ .../tv/fragment/TvPlaybackAudioFragment.java | 417 ++++++ .../tv/fragment/TvPlaybackVideoFragment.java | 448 ++++++ .../AudioDetailsDescriptionPresenter.java | 46 + .../tv/presenter/IconHeaderPresenter.java | 84 ++ .../tv/presenter/MainTVPresenter.java | 263 ++++ .../tv/presenter/SettingsItemPresenter.java | 104 ++ .../VideoDetailsDescriptionPresenter.java | 57 + .../java/org/amahi/anywhere/util/Android.java | 21 + .../anywhere/util/AudioMetadataFormatter.java | 17 + .../anywhere/util/CastOptionsProvider.java | 61 + .../java/org/amahi/anywhere/util/CheckTV.java | 34 + .../org/amahi/anywhere/util/Downloader.java | 29 +- .../org/amahi/anywhere/util/Fragments.java | 60 +- .../amahi/anywhere/util/FullScreenHelper.java | 12 +- .../org/amahi/anywhere/util/Identifier.java | 13 +- .../java/org/amahi/anywhere/util/Intents.java | 72 +- .../util/MediaNotificationManager.java | 148 +- .../java/org/amahi/anywhere/util/Mimes.java | 77 + .../org/amahi/anywhere/util/NetworkUtils.java | 64 + .../org/amahi/anywhere/util/Preferences.java | 56 +- .../anywhere/util/ProgressRequestBody.java | 104 ++ .../util/RecyclerItemClickListener.java | 30 +- .../org/amahi/anywhere/util/SampleSlide.java | 78 + .../java/org/amahi/anywhere/util/Time.java | 9 + .../amahi/anywhere/util/UploadManager.java | 156 ++ .../anywhere/util/VideoSwipeGestures.java | 243 +++ .../amahi/anywhere/view/AudioControls.java | 43 + .../amahi/anywhere/view/MediaControls.java | 90 +- .../amahi/anywhere/view/PercentageView.java | 104 ++ .../org/amahi/anywhere/view/SeekView.java | 44 + .../amahi/anywhere/view/TouchImageView.java | 24 +- src/main/res/drawable-hdpi/movies.png | Bin 0 -> 25061 bytes src/main/res/drawable-hdpi/music.png | Bin 0 -> 43519 bytes src/main/res/drawable-hdpi/network.png | Bin 0 -> 6686 bytes src/main/res/drawable-hdpi/photos.png | Bin 0 -> 38228 bytes src/main/res/drawable-hdpi/tick.png | Bin 0 -> 26771 bytes src/main/res/drawable-hdpi/tv_ic_archive.png | Bin 0 -> 5228 bytes src/main/res/drawable-hdpi/tv_ic_audio.png | Bin 0 -> 5308 bytes src/main/res/drawable-hdpi/tv_ic_code.png | Bin 0 -> 4814 bytes src/main/res/drawable-hdpi/tv_ic_document.png | Bin 0 -> 1461 bytes src/main/res/drawable-hdpi/tv_ic_folder.png | Bin 0 -> 1974 bytes src/main/res/drawable-hdpi/tv_ic_generic.png | Bin 0 -> 1913 bytes src/main/res/drawable-hdpi/tv_ic_images.png | Bin 0 -> 8522 bytes .../res/drawable-hdpi/tv_ic_presentation.png | Bin 0 -> 5033 bytes .../res/drawable-hdpi/tv_ic_spreadsheet.png | Bin 0 -> 2422 bytes src/main/res/drawable-hdpi/tv_ic_video.png | Bin 0 -> 3492 bytes .../drawable-nodpi/ic_app_logo_shadowless.png | Bin 0 -> 38658 bytes src/main/res/drawable-xhdpi/tv_banner.png | Bin 0 -> 18592 bytes src/main/res/drawable/ic_add.xml | 9 + src/main/res/drawable/ic_brightness.xml | 13 + src/main/res/drawable/ic_camera.xml | 12 + src/main/res/drawable/ic_cloud_upload.xml | 9 + src/main/res/drawable/ic_delete.xml | 12 + src/main/res/drawable/ic_volume_up.xml | 14 + src/main/res/drawable/percentage_bar.xml | 19 + src/main/res/drawable/splash_page.xml | 12 + src/main/res/layout/activity_intro.xml | 11 + src/main/res/layout/activity_main_tv.xml | 5 + src/main/res/layout/activity_native_video.xml | 65 + src/main/res/layout/activity_navigation.xml | 8 +- .../res/layout/activity_server_file_audio.xml | 1 + .../res/layout/activity_server_file_tv.xml | 6 + .../res/layout/activity_server_file_video.xml | 56 +- .../res/layout/activity_server_file_web.xml | 3 +- src/main/res/layout/activity_server_files.xml | 37 +- src/main/res/layout/activity_settings.xml | 9 +- .../res/layout/activity_tv_audio_playback.xml | 20 + .../res/layout/activity_tv_video_playback.xml | 19 + src/main/res/layout/activity_tv_web.xml | 12 + src/main/res/layout/container_files_dummy.xml | 6 + src/main/res/layout/intro_first_layout.xml | 51 + src/main/res/layout/onboarding_image.xml | 6 + src/main/res/layout/percentage_view.xml | 46 + src/main/res/layout/seek_view.xml | 17 + src/main/res/layout/subtitles_surface.xml | 4 + src/main/res/layout/tv_header_item.xml | 21 + src/main/res/layout/tv_loading.xml | 22 + src/main/res/layout/upload_bottom_sheet.xml | 27 + src/main/res/layout/upload_list_item.xml | 25 + src/main/res/layout/view_server_file_item.xml | 5 +- src/main/res/menu/action_bar_cast_button.xml | 30 + .../menu/action_bar_expanded_controller.xml | 30 + .../res/menu/action_bar_server_file_image.xml | 11 +- src/main/res/menu/action_bar_server_files.xml | 9 +- .../res/menu/action_mode_server_files.xml | 8 +- src/main/res/values-v21/themes.xml | 10 +- src/main/res/values/colors.xml | 8 + src/main/res/values/preferences.xml | 46 + src/main/res/values/strings.xml | 78 +- src/main/res/values/themes.xml | 8 + src/main/res/xml/file_paths.xml | 9 + src/main/res/xml/settings.xml | 4 + src/main/res/xml/upload_settings.xml | 48 + .../org/amahi/anywhere/PermissionTest.java | 27 +- 206 files changed, 15031 insertions(+), 6192 deletions(-) create mode 100644 .editorconfig create mode 100644 DEBUG.md create mode 100644 api.properties.sample create mode 100644 gradle.properties create mode 100644 src/main/assets/.gitignore create mode 100644 src/main/java/org/amahi/anywhere/activity/ExpandedControlsActivity.java create mode 100644 src/main/java/org/amahi/anywhere/activity/IntroductionActivity.java create mode 100644 src/main/java/org/amahi/anywhere/activity/NativeVideoActivity.java create mode 100644 src/main/java/org/amahi/anywhere/activity/SplashActivity.java create mode 100644 src/main/java/org/amahi/anywhere/adapter/UploadOptionsAdapter.java create mode 100644 src/main/java/org/amahi/anywhere/bus/ServerFileDeleteEvent.java create mode 100644 src/main/java/org/amahi/anywhere/bus/ServerFileUploadCompleteEvent.java create mode 100644 src/main/java/org/amahi/anywhere/bus/ServerFileUploadProgressEvent.java create mode 100644 src/main/java/org/amahi/anywhere/bus/UploadClickEvent.java create mode 100644 src/main/java/org/amahi/anywhere/bus/UploadSettingsOpeningEvent.java create mode 100644 src/main/java/org/amahi/anywhere/db/UploadQueueDb.java create mode 100644 src/main/java/org/amahi/anywhere/db/UploadQueueDbHelper.java create mode 100644 src/main/java/org/amahi/anywhere/fragment/UploadBottomSheet.java create mode 100644 src/main/java/org/amahi/anywhere/fragment/UploadSettingsFragment.java create mode 100644 src/main/java/org/amahi/anywhere/job/NetConnectivityJob.java create mode 100644 src/main/java/org/amahi/anywhere/job/PhotosContentJob.java create mode 100644 src/main/java/org/amahi/anywhere/model/UploadFile.java create mode 100644 src/main/java/org/amahi/anywhere/model/UploadOption.java create mode 100644 src/main/java/org/amahi/anywhere/receiver/CameraReceiver.java create mode 100644 src/main/java/org/amahi/anywhere/server/response/ServerFileDeleteResponse.java create mode 100644 src/main/java/org/amahi/anywhere/server/response/ServerFileUploadResponse.java create mode 100644 src/main/java/org/amahi/anywhere/service/UploadService.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/IntroActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/MainTVActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/ServerFileTvActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/SettingsActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/TVWebViewActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackAudioActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackVideoActivity.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/ConnectionFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/IntroFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/MainTVFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/ServerFileTvFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/ServerSelectFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/SignOutFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/ThemeFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackAudioFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackVideoFragment.java create mode 100644 src/main/java/org/amahi/anywhere/tv/presenter/AudioDetailsDescriptionPresenter.java create mode 100644 src/main/java/org/amahi/anywhere/tv/presenter/IconHeaderPresenter.java create mode 100644 src/main/java/org/amahi/anywhere/tv/presenter/MainTVPresenter.java create mode 100644 src/main/java/org/amahi/anywhere/tv/presenter/SettingsItemPresenter.java create mode 100644 src/main/java/org/amahi/anywhere/tv/presenter/VideoDetailsDescriptionPresenter.java create mode 100644 src/main/java/org/amahi/anywhere/util/CastOptionsProvider.java create mode 100644 src/main/java/org/amahi/anywhere/util/CheckTV.java create mode 100644 src/main/java/org/amahi/anywhere/util/NetworkUtils.java create mode 100644 src/main/java/org/amahi/anywhere/util/ProgressRequestBody.java create mode 100644 src/main/java/org/amahi/anywhere/util/SampleSlide.java create mode 100644 src/main/java/org/amahi/anywhere/util/UploadManager.java create mode 100644 src/main/java/org/amahi/anywhere/util/VideoSwipeGestures.java create mode 100644 src/main/java/org/amahi/anywhere/view/AudioControls.java create mode 100644 src/main/java/org/amahi/anywhere/view/PercentageView.java create mode 100644 src/main/java/org/amahi/anywhere/view/SeekView.java create mode 100644 src/main/res/drawable-hdpi/movies.png create mode 100644 src/main/res/drawable-hdpi/music.png create mode 100644 src/main/res/drawable-hdpi/network.png create mode 100644 src/main/res/drawable-hdpi/photos.png create mode 100644 src/main/res/drawable-hdpi/tick.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_archive.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_audio.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_code.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_document.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_folder.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_generic.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_images.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_presentation.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_spreadsheet.png create mode 100644 src/main/res/drawable-hdpi/tv_ic_video.png create mode 100644 src/main/res/drawable-nodpi/ic_app_logo_shadowless.png create mode 100644 src/main/res/drawable-xhdpi/tv_banner.png create mode 100644 src/main/res/drawable/ic_add.xml create mode 100644 src/main/res/drawable/ic_brightness.xml create mode 100644 src/main/res/drawable/ic_camera.xml create mode 100644 src/main/res/drawable/ic_cloud_upload.xml create mode 100644 src/main/res/drawable/ic_delete.xml create mode 100644 src/main/res/drawable/ic_volume_up.xml create mode 100644 src/main/res/drawable/percentage_bar.xml create mode 100644 src/main/res/drawable/splash_page.xml create mode 100644 src/main/res/layout/activity_intro.xml create mode 100644 src/main/res/layout/activity_main_tv.xml create mode 100644 src/main/res/layout/activity_native_video.xml create mode 100644 src/main/res/layout/activity_server_file_tv.xml create mode 100644 src/main/res/layout/activity_tv_audio_playback.xml create mode 100644 src/main/res/layout/activity_tv_video_playback.xml create mode 100644 src/main/res/layout/activity_tv_web.xml create mode 100644 src/main/res/layout/container_files_dummy.xml create mode 100644 src/main/res/layout/intro_first_layout.xml create mode 100644 src/main/res/layout/onboarding_image.xml create mode 100644 src/main/res/layout/percentage_view.xml create mode 100644 src/main/res/layout/seek_view.xml create mode 100644 src/main/res/layout/subtitles_surface.xml create mode 100644 src/main/res/layout/tv_header_item.xml create mode 100644 src/main/res/layout/tv_loading.xml create mode 100644 src/main/res/layout/upload_bottom_sheet.xml create mode 100644 src/main/res/layout/upload_list_item.xml create mode 100644 src/main/res/menu/action_bar_cast_button.xml create mode 100644 src/main/res/menu/action_bar_expanded_controller.xml create mode 100644 src/main/res/xml/file_paths.xml create mode 100644 src/main/res/xml/upload_settings.xml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..3cdbecb97 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +# Change these settings to your own preference +indent_style = space +indent_size = 4 + +# It's recommended to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore index a264fd219..5ecda83bd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,6 @@ fabric.properties # Key signing.properties +amahi-release-key.keystore .DS* diff --git a/.travis.yml b/.travis.yml index e4c9ce70a..7af9ccd74 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ android: - tools - tools - platform-tools - - build-tools-25.0.2 + - build-tools-27.0.1 - android-25 - android-19 - extra-android-support diff --git a/DEBUG.md b/DEBUG.md new file mode 100644 index 000000000..365e16dbb --- /dev/null +++ b/DEBUG.md @@ -0,0 +1,24 @@ +# Debugging + +Sometimes you may need to debug with some special purpose server. To do that, add a file like this + + src/main/assets/customServers.json + +with details of the custom server(s) you need, like this: + + ``` + [ + { + "name": "Test Server 1", + "session_token": "12345678901234567", + "local_address": "http://192.168.0.11:4563", + "remote_address": "http://192.168.12.22:4563" + }, + { + "name": "Test Server 2", + "session_token": "12345678901234567", + "local_address": "http://192.168.0.11:4563", + "remote_address": "http://192.168.12.22:4563" + } + ] + ``` diff --git a/api.properties.sample b/api.properties.sample new file mode 100644 index 000000000..55224af28 --- /dev/null +++ b/api.properties.sample @@ -0,0 +1,8 @@ +# Set API information in api.properties. +# This is something to keep private and you obtain it by asking in the Amahi IRC channel or +# send a message to support at Amahi dot org + +url.amahi=https://amahi.org +url.proxy=https://amahi.org +client.id=1234567890 +client.secret=abcdefghijklmnopqrstuvwxyz diff --git a/build.gradle b/build.gradle index 3eeaae4f9..6f69b2336 100644 --- a/build.gradle +++ b/build.gradle @@ -2,10 +2,11 @@ buildscript { repositories { jcenter() maven { url 'https://maven.fabric.io/public' } + google() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.1' + classpath 'com.android.tools.build:gradle:3.0.1' classpath 'io.fabric.tools:gradle:1.22.1' } } @@ -15,7 +16,7 @@ apply plugin: 'io.fabric' android { compileSdkVersion 25 - buildToolsVersion "25.0.2" + buildToolsVersion '27.0.1' signingConfigs { release { @@ -38,29 +39,33 @@ android { defaultConfig { - def versionMajor = 2 - def versionMinor = 4 + def versionMajor = 3 + def versionMinor = 0 def versionPatch = 0 versionName buildVersionName(versionMajor, versionMinor, versionPatch) versionCode buildVersionCode(versionMajor, versionMinor, versionPatch) - minSdkVersion 15 + minSdkVersion 17 targetSdkVersion 25 def apiFile = file("api.properties") - def fakeApiFile = file("fakeApi.properties") def apiProperties = new Properties() + // NOTE-cpg: this fake api is here for the tests to pass + def fakeApiFile = file("fakeApi.properties") + if (apiFile.exists()) { apiProperties.load(apiFile.newInputStream()) } else { apiProperties.load(fakeApiFile.newInputStream()) } + buildConfigField "String", "API_URL_AMAHI", formatStringField(apiProperties["url.amahi"]) buildConfigField "String", "API_URL_PROXY", formatStringField(apiProperties["url.proxy"]) buildConfigField "String", "API_CLIENT_ID", formatStringField(apiProperties["client.id"]) buildConfigField "String", "API_CLIENT_SECRET", formatStringField(apiProperties["client.secret"]) + buildConfigField "String", "CHROMECAST_APP_ID", formatStringField(apiProperties["chromecast.app.id"]) testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } @@ -82,28 +87,29 @@ android { } } -def buildVersionName(versionMajor, versionMinor, versionPatch) { +static def buildVersionName(versionMajor, versionMinor, versionPatch) { return "${versionMajor}.${versionMinor}.${versionPatch}" } -def buildVersionCode(versionMajor, versionMinor, versionPatch) { +static def buildVersionCode(versionMajor, versionMinor, versionPatch) { return versionMajor * 10**2 + versionMinor * 10**1 + versionPatch * 10**0 } -def formatStringField(field) { +static def formatStringField(field) { return "\"${field}\"" } dependencies { repositories { + jcenter() mavenCentral() mavenLocal() maven { url 'https://maven.fabric.io/public' } maven { url 'http://dl.bintray.com/megabitdragon/maven' } + maven { url 'https://jitpack.io' } + google() } - - def SUPPORT_LIBRARY_VERSION = "25.3.1" - + def SUPPORT_LIBRARY_VERSION = "25.4.0" compile "com.android.support:support-v4:${SUPPORT_LIBRARY_VERSION}" compile "com.android.support:support-v13:${SUPPORT_LIBRARY_VERSION}" compile "com.android.support:recyclerview-v7:${SUPPORT_LIBRARY_VERSION}" @@ -111,26 +117,34 @@ dependencies { compile "com.android.support:design:${SUPPORT_LIBRARY_VERSION}" compile "com.android.support:preference-v7:${SUPPORT_LIBRARY_VERSION}" compile "com.android.support:customtabs:${SUPPORT_LIBRARY_VERSION}" - compile "com.github.dmytrodanylyk.android-process-button:library:1.0.4" - compile "com.jakewharton.timber:timber:4.5.1" - compile "com.squareup.dagger:dagger:1.2.5" - compile "com.squareup:otto:1.3.8" - compile 'com.github.bumptech.glide:glide:3.7.0' - compile "com.squareup.retrofit2:retrofit:2.2.0" - compile 'com.squareup.retrofit2:converter-gson:2.2.0' - compile 'com.squareup.okhttp3:logging-interceptor:3.6.0' - compile "org.videolan:libvlc:2.1.1" + compile "com.android.support:leanback-v17:${SUPPORT_LIBRARY_VERSION}" + compile "com.android.support:mediarouter-v7:${SUPPORT_LIBRARY_VERSION}" + compile "com.android.support:cardview-v7:${SUPPORT_LIBRARY_VERSION}" + compile 'com.google.android.gms:play-services-cast-framework:11.6.0' compile('com.crashlytics.sdk.android:crashlytics:2.6.6@aar') { transitive = true; } - provided "com.squareup.dagger:dagger-compiler:1.2.5" + compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' + compile 'com.jakewharton.timber:timber:4.5.1' + compile 'com.squareup.dagger:dagger:1.2.5' + annotationProcessor 'com.squareup.dagger:dagger-compiler:1.2.5' + compile 'com.squareup:otto:1.3.8' + compile 'com.github.apl-devs:appintro:v4.2.0' + compile 'com.github.bumptech.glide:glide:3.7.0' + compile 'com.squareup.retrofit2:retrofit:2.3.0' + compile 'com.squareup.retrofit2:converter-gson:2.3.0' + compile 'com.squareup.okhttp3:logging-interceptor:3.8.0' + compile 'org.videolan:libvlc:2.1.1' + compile 'pub.devrel:easypermissions:0.4.2' testCompile 'org.robolectric:robolectric:3.1.2' testCompile 'junit:junit:4.12' testCompile 'org.robolectric:shadows-multidex:3.0' - androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2' - androidTestCompile 'com.android.support.test:runner:0.5' - androidTestCompile 'com.android.support:support-annotations:25.3.1' + androidTestCompile 'com.android.support.test.espresso:espresso-core:3.0.1' + androidTestCompile 'com.android.support.test:runner:1.0.1' + androidTestCompile 'com.android.support:support-annotations:25.4.0' + provided 'com.squareup.dagger:dagger-compiler:1.2.5' + compile 'com.github.wseemann:FFmpegMediaMetadataRetriever:1.0.14' } task generateWrapper(type: Wrapper) { @@ -154,4 +168,4 @@ android.applicationVariants.all { variant -> linksOffline "http://d.android.com/reference", "${android.sdkDirectory}/docs/reference" } } -} \ No newline at end of file +} diff --git a/fakeApi.properties b/fakeApi.properties index 55224af28..5d0473ebe 100644 --- a/fakeApi.properties +++ b/fakeApi.properties @@ -1,8 +1,8 @@ # Set API information in api.properties. # This is something to keep private and you obtain it by asking in the Amahi IRC channel or # send a message to support at Amahi dot org - url.amahi=https://amahi.org url.proxy=https://amahi.org client.id=1234567890 client.secret=abcdefghijklmnopqrstuvwxyz +chromecast.app.id=CC1AD845 diff --git a/fakeSigning.properties b/fakeSigning.properties index 8ad82e1af..86d6879d8 100644 --- a/fakeSigning.properties +++ b/fakeSigning.properties @@ -1,7 +1,6 @@ # Set API information in api.properties. # This is something to keep private and you obtain it by asking in the Amahi IRC channel or # send a message to support at Amahi dot org - keystore.file=debug.keystore keystore.password=android key.alias=androiddebugkey diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..e8b10e2b5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +android.enableAapt2=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e1a87e33..a0bba0de9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Mar 02 19:36:19 CST 2017 +#Sun Nov 26 20:09:44 IST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-3.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip diff --git a/src/androidTest/java/org/amahi/anywhere/activity/AuthenticationActivityTest.java b/src/androidTest/java/org/amahi/anywhere/activity/AuthenticationActivityTest.java index 5794e9c6a..aa8fd79f0 100644 --- a/src/androidTest/java/org/amahi/anywhere/activity/AuthenticationActivityTest.java +++ b/src/androidTest/java/org/amahi/anywhere/activity/AuthenticationActivityTest.java @@ -20,7 +20,7 @@ public class AuthenticationActivityTest { @Rule public ActivityTestRule authenticationActivityTestRule = - new ActivityTestRule(AuthenticationActivity.class); + new ActivityTestRule(AuthenticationActivity.class); @Test public void testIsErrorMessageDisplayed_UsernameOrPasswordIsEmpty() { @@ -28,10 +28,10 @@ public void testIsErrorMessageDisplayed_UsernameOrPasswordIsEmpty() { onView(withId(R.id.password_layout)).check(matches(isDisplayed())); onView(withId(R.id.button_authentication)) - .check(matches(isDisplayed())) - .perform(click()); + .check(matches(isDisplayed())) + .perform(click()); onView(withId(R.id.text_message_authentication_empty)) - .check(matches(isDisplayed())); + .check(matches(isDisplayed())); } } diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index eb2b62d11..a29da2fc6 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -30,30 +30,66 @@ + + + + + + - + + + + + + + + + + + + + android:value="2.1" /> + + + android:resource="@xml/searchable" /> + + + + + + + - + @@ -111,6 +166,24 @@ + + + + + + + + + + + + @@ -118,6 +191,34 @@ + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/main/assets/.gitignore b/src/main/assets/.gitignore new file mode 100644 index 000000000..fd77dc41a --- /dev/null +++ b/src/main/assets/.gitignore @@ -0,0 +1 @@ +customServers.json \ No newline at end of file diff --git a/src/main/java/org/amahi/anywhere/AmahiApplication.java b/src/main/java/org/amahi/anywhere/AmahiApplication.java index 6ce1a3243..c27a03288 100644 --- a/src/main/java/org/amahi/anywhere/AmahiApplication.java +++ b/src/main/java/org/amahi/anywhere/AmahiApplication.java @@ -21,10 +21,15 @@ import android.app.Application; import android.content.Context; +import android.os.Build; import android.os.StrictMode; +import android.support.annotation.RequiresApi; import com.crashlytics.android.Crashlytics; +import org.amahi.anywhere.job.NetConnectivityJob; +import org.amahi.anywhere.job.PhotosContentJob; + import dagger.ObjectGraph; import io.fabric.sdk.android.Fabric; import timber.log.Timber; @@ -49,6 +54,10 @@ public void onCreate() { setUpDetecting(); setUpInjections(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setUpJobs(); + } } private void setUpLogging() { @@ -80,4 +89,19 @@ private void setUpInjections() { public void inject(Object injectionsConsumer) { injector.inject(injectionsConsumer); } + + @RequiresApi(api = Build.VERSION_CODES.N) + private void setUpJobs() { + if (!PhotosContentJob.isScheduled(this)) { + PhotosContentJob.scheduleJob(this); + } + if (!NetConnectivityJob.isScheduled(this)) { + NetConnectivityJob.scheduleJob(this); + } + } + + public static class JobIds { + public static final int PHOTOS_CONTENT_JOB = 125; + public static final int NET_CONNECTIVITY_JOB = 126; + } } diff --git a/src/main/java/org/amahi/anywhere/AmahiModule.java b/src/main/java/org/amahi/anywhere/AmahiModule.java index 03ae4ecb8..d6999e732 100644 --- a/src/main/java/org/amahi/anywhere/AmahiModule.java +++ b/src/main/java/org/amahi/anywhere/AmahiModule.java @@ -23,6 +23,7 @@ import android.content.Context; import org.amahi.anywhere.activity.AuthenticationActivity; +import org.amahi.anywhere.activity.NativeVideoActivity; import org.amahi.anywhere.activity.NavigationActivity; import org.amahi.anywhere.activity.ServerAppActivity; import org.amahi.anywhere.activity.ServerFileAudioActivity; @@ -37,9 +38,19 @@ import org.amahi.anywhere.fragment.ServerFilesFragment; import org.amahi.anywhere.fragment.ServerSharesFragment; import org.amahi.anywhere.fragment.SettingsFragment; +import org.amahi.anywhere.fragment.UploadSettingsFragment; import org.amahi.anywhere.server.ApiModule; import org.amahi.anywhere.service.AudioService; +import org.amahi.anywhere.service.UploadService; import org.amahi.anywhere.service.VideoService; +import org.amahi.anywhere.tv.activity.TVWebViewActivity; +import org.amahi.anywhere.tv.activity.TvPlaybackAudioActivity; +import org.amahi.anywhere.tv.activity.TvPlaybackVideoActivity; +import org.amahi.anywhere.tv.fragment.MainTVFragment; +import org.amahi.anywhere.tv.fragment.ServerFileTvFragment; +import org.amahi.anywhere.tv.fragment.TvPlaybackAudioFragment; +import org.amahi.anywhere.tv.fragment.TvPlaybackVideoFragment; +import org.amahi.anywhere.util.UploadManager; import javax.inject.Singleton; @@ -51,28 +62,39 @@ * provides application's {@link android.content.Context} for possible consumers. */ @Module( - includes = { - ApiModule.class - }, - injects = { - AuthenticationActivity.class, - NavigationActivity.class, - ServerAppActivity.class, - ServerFilesActivity.class, - ServerFileAudioActivity.class, - ServerFileImageActivity.class, - ServerFileVideoActivity.class, - ServerFileWebActivity.class, - NavigationFragment.class, - ServerSharesFragment.class, - ServerAppsFragment.class, - ServerFilesFragment.class, - ServerFileImageFragment.class, - ServerFileDownloadingFragment.class, - SettingsFragment.class, - AudioService.class, - VideoService.class - } + includes = { + ApiModule.class + }, + injects = { + AuthenticationActivity.class, + NavigationActivity.class, + ServerAppActivity.class, + ServerFilesActivity.class, + ServerFileAudioActivity.class, + ServerFileImageActivity.class, + ServerFileVideoActivity.class, + NativeVideoActivity.class, + ServerFileWebActivity.class, + NavigationFragment.class, + ServerSharesFragment.class, + ServerAppsFragment.class, + ServerFilesFragment.class, + ServerFileImageFragment.class, + ServerFileDownloadingFragment.class, + SettingsFragment.class, + UploadSettingsFragment.class, + AudioService.class, + VideoService.class, + MainTVFragment.class, + TVWebViewActivity.class, + ServerFileTvFragment.class, + UploadService.class, + UploadManager.class, + TvPlaybackVideoFragment.class, + TvPlaybackVideoActivity.class, + TvPlaybackAudioActivity.class, + TvPlaybackAudioFragment.class + } ) class AmahiModule { private final Application application; diff --git a/src/main/java/org/amahi/anywhere/account/AmahiAccount.java b/src/main/java/org/amahi/anywhere/account/AmahiAccount.java index 0588edc5e..cb940e13e 100644 --- a/src/main/java/org/amahi/anywhere/account/AmahiAccount.java +++ b/src/main/java/org/amahi/anywhere/account/AmahiAccount.java @@ -24,13 +24,12 @@ /** * Amahi account declaration. */ -public class AmahiAccount extends Account -{ - public static final String TYPE = "org.amahi"; +public class AmahiAccount extends Account { + public static final String TYPE = "org.amahi"; - public static final String TYPE_TOKEN = String.format("%s.FULL", TYPE); + public static final String TYPE_TOKEN = String.format("%s.FULL", TYPE); - public AmahiAccount(String name) { - super(name, TYPE); - } + public AmahiAccount(String name) { + super(name, TYPE); + } } diff --git a/src/main/java/org/amahi/anywhere/account/AmahiAuthenticator.java b/src/main/java/org/amahi/anywhere/account/AmahiAuthenticator.java index 6b2124901..5eba04d23 100644 --- a/src/main/java/org/amahi/anywhere/account/AmahiAuthenticator.java +++ b/src/main/java/org/amahi/anywhere/account/AmahiAuthenticator.java @@ -37,82 +37,81 @@ /** * Amahi authenticator. Performs basic account and auth token manipulations. - * + *

* The current implementation allows only single account exist on a device. */ -class AmahiAuthenticator extends AbstractAccountAuthenticator -{ - private final Context context; - - public AmahiAuthenticator(Context context) { - super(context); - - this.context = context; - } - - @Override - public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException { - Bundle accountBundle = new Bundle(); - - if (getAccounts().isEmpty()) { - Intent accountIntent = new Intent(context, AuthenticationActivity.class); - accountIntent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); - - accountBundle.putParcelable(AccountManager.KEY_INTENT, accountIntent); - } else { - accountBundle.putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_CANCELED); - accountBundle.putString(AccountManager.KEY_ERROR_MESSAGE, context.getString(R.string.message_error_account_exists)); - } - - return accountBundle; - } - - private List getAccounts() { - return Arrays.asList(AccountManager.get(context).getAccountsByType(AmahiAccount.TYPE)); - } - - @Override - public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { - Bundle authBundle = new Bundle(); - - String authToken = AccountManager.get(context).peekAuthToken(account, authTokenType); - - if (!TextUtils.isEmpty(authToken)) { - authBundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); - authBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); - authBundle.putString(AccountManager.KEY_AUTHTOKEN, authToken); - } else { - Intent authIntent = new Intent(context, AuthenticationActivity.class); - authIntent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); - - authBundle.putParcelable(AccountManager.KEY_INTENT, authIntent); - } - - return authBundle; - } - - @Override - public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { - return null; - } - - @Override - public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException { - return null; - } - - @Override - public String getAuthTokenLabel(String authTokenType) { - return null; - } - - @Override - public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { - return null; - } - - @Override - public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException { - return null; - } +class AmahiAuthenticator extends AbstractAccountAuthenticator { + private final Context context; + + public AmahiAuthenticator(Context context) { + super(context); + + this.context = context; + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException { + Bundle accountBundle = new Bundle(); + + if (getAccounts().isEmpty()) { + Intent accountIntent = new Intent(context, AuthenticationActivity.class); + accountIntent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); + + accountBundle.putParcelable(AccountManager.KEY_INTENT, accountIntent); + } else { + accountBundle.putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_CANCELED); + accountBundle.putString(AccountManager.KEY_ERROR_MESSAGE, context.getString(R.string.message_error_account_exists)); + } + + return accountBundle; + } + + private List getAccounts() { + return Arrays.asList(AccountManager.get(context).getAccountsByType(AmahiAccount.TYPE)); + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { + Bundle authBundle = new Bundle(); + + String authToken = AccountManager.get(context).peekAuthToken(account, authTokenType); + + if (!TextUtils.isEmpty(authToken)) { + authBundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); + authBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); + authBundle.putString(AccountManager.KEY_AUTHTOKEN, authToken); + } else { + Intent authIntent = new Intent(context, AuthenticationActivity.class); + authIntent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); + + authBundle.putParcelable(AccountManager.KEY_INTENT, authIntent); + } + + return authBundle; + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + return null; + } + + @Override + public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public String getAuthTokenLabel(String authTokenType) { + return null; + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException { + return null; + } } diff --git a/src/main/java/org/amahi/anywhere/account/AmahiAuthenticatorService.java b/src/main/java/org/amahi/anywhere/account/AmahiAuthenticatorService.java index 66371307e..aa7a4e5ed 100644 --- a/src/main/java/org/amahi/anywhere/account/AmahiAuthenticatorService.java +++ b/src/main/java/org/amahi/anywhere/account/AmahiAuthenticatorService.java @@ -27,19 +27,18 @@ * Amahi authenticator service. * Allows {@link android.accounts.AccountManager} to interact with{@link AmahiAuthenticator}. */ -public class AmahiAuthenticatorService extends Service -{ - private AmahiAuthenticator authenticator; +public class AmahiAuthenticatorService extends Service { + private AmahiAuthenticator authenticator; - @Override - public void onCreate() { - super.onCreate(); + @Override + public void onCreate() { + super.onCreate(); - authenticator = new AmahiAuthenticator(getApplicationContext()); - } + authenticator = new AmahiAuthenticator(getApplicationContext()); + } - @Override - public IBinder onBind(Intent intent) { - return authenticator.getIBinder(); - } + @Override + public IBinder onBind(Intent intent) { + return authenticator.getIBinder(); + } } diff --git a/src/main/java/org/amahi/anywhere/activity/AuthenticationActivity.java b/src/main/java/org/amahi/anywhere/activity/AuthenticationActivity.java index c807a753e..bf0f18e8e 100644 --- a/src/main/java/org/amahi/anywhere/activity/AuthenticationActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/AuthenticationActivity.java @@ -55,259 +55,257 @@ * Authentication activity. Allows user authentication. If operation succeed * the authentication token is saved at the {@link android.accounts.AccountManager}. */ -public class AuthenticationActivity extends AccountAuthenticatorActivity implements TextWatcher, View.OnClickListener -{ - @Inject - AmahiClient amahiClient; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_authentication); - - setUpInjections(); - - setUpAuthentication(); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpAuthentication() { - setUpAuthenticationMessages(); - setUpAuthenticationListeners(); - } - - private String getUsername() { - return getUsernameEdit().getText().toString(); - } - - private EditText getUsernameEdit() { - TextInputLayout username_layout = (TextInputLayout) findViewById(R.id.username_layout); - return username_layout.getEditText(); - } - - private String getPassword() { - return getPasswordEdit().getText().toString(); - } - - private EditText getPasswordEdit() { - TextInputLayout password_layout = (TextInputLayout) findViewById(R.id.password_layout); - return password_layout.getEditText(); - } - - private ActionProcessButton getAuthenticationButton() { - return (ActionProcessButton) findViewById(R.id.button_authentication); - } - - private void setUpAuthenticationMessages() { - TextView authenticationFailureMessage = (TextView) findViewById(R.id.text_message_authentication); - TextView authenticationConnectionFailureMessage = (TextView) findViewById(R.id.text_message_authentication_connection); - - authenticationFailureMessage.setMovementMethod(LinkMovementMethod.getInstance()); - authenticationConnectionFailureMessage.setMovementMethod(LinkMovementMethod.getInstance()); - } - - private void setUpAuthenticationListeners() { - setUpAuthenticationTextListener(); - setUpAuthenticationActionListener(); - } - - private void setUpAuthenticationTextListener() { - getUsernameEdit().addTextChangedListener(this); - getPasswordEdit().addTextChangedListener(this); - getPasswordEdit().setOnEditorActionListener(new EditText.OnEditorActionListener() { - @Override - public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { - boolean handled = false; - if (actionId == EditorInfo.IME_ACTION_GO) { - onClick(getAuthenticationButton()); - handled = true; - } - return handled; - } - }); - } - - @Override - public void onTextChanged(CharSequence text, int after, int before, int count) { - hideAuthenticationFailureMessage(); - } - - private void hideAuthenticationFailureMessage() { - ViewDirector.of(this, R.id.animator_message).show(R.id.view_message_empty); - } - - @Override - public void afterTextChanged(Editable text) { - } - - @Override - public void beforeTextChanged(CharSequence text, int start, int count, int before) { - } - - private void setUpAuthenticationActionListener() { - getAuthenticationButton().setOnClickListener(this); - } - - @Override - public void onClick(View view) { - if(getUsername().trim().isEmpty() || getPassword().trim().isEmpty()){ - ViewDirector.of(this,R.id.animator_message).show(R.id.text_message_authentication_empty); - - if(getUsername().trim().isEmpty()) - getUsernameEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, android.R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); - if(getPassword().trim().isEmpty()) - getPasswordEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, android.R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); - - getUsernameEdit().addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { - - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { - if(!getUsername().trim().isEmpty()) - getUsernameEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, R.color.blue_normal),PorterDuff.Mode.SRC_ATOP); - else - getUsernameEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); - } - - @Override - public void afterTextChanged(Editable editable) { - - } - }); - - getPasswordEdit().addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { - - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { - if(!getPassword().trim().isEmpty()) - getPasswordEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, R.color.blue_normal),PorterDuff.Mode.SRC_ATOP); - else - getPasswordEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, android.R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); - } +public class AuthenticationActivity extends AccountAuthenticatorActivity implements TextWatcher, View.OnClickListener { + @Inject + AmahiClient amahiClient; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_authentication); + + setUpInjections(); + + setUpAuthentication(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpAuthentication() { + setUpAuthenticationMessages(); + setUpAuthenticationListeners(); + } + + private String getUsername() { + return getUsernameEdit().getText().toString(); + } + + private EditText getUsernameEdit() { + TextInputLayout username_layout = (TextInputLayout) findViewById(R.id.username_layout); + return username_layout.getEditText(); + } + + private String getPassword() { + return getPasswordEdit().getText().toString(); + } + + private EditText getPasswordEdit() { + TextInputLayout password_layout = (TextInputLayout) findViewById(R.id.password_layout); + return password_layout.getEditText(); + } + + private ActionProcessButton getAuthenticationButton() { + return (ActionProcessButton) findViewById(R.id.button_authentication); + } + + private void setUpAuthenticationMessages() { + TextView authenticationFailureMessage = (TextView) findViewById(R.id.text_message_authentication); + TextView authenticationConnectionFailureMessage = (TextView) findViewById(R.id.text_message_authentication_connection); + + authenticationFailureMessage.setMovementMethod(LinkMovementMethod.getInstance()); + authenticationConnectionFailureMessage.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void setUpAuthenticationListeners() { + setUpAuthenticationTextListener(); + setUpAuthenticationActionListener(); + } + + private void setUpAuthenticationTextListener() { + getUsernameEdit().addTextChangedListener(this); + getPasswordEdit().addTextChangedListener(this); + getPasswordEdit().setOnEditorActionListener(new EditText.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + boolean handled = false; + if (actionId == EditorInfo.IME_ACTION_GO) { + onClick(getAuthenticationButton()); + handled = true; + } + return handled; + } + }); + } + + @Override + public void onTextChanged(CharSequence text, int after, int before, int count) { + hideAuthenticationFailureMessage(); + } + + private void hideAuthenticationFailureMessage() { + ViewDirector.of(this, R.id.animator_message).show(R.id.view_message_empty); + } + + @Override + public void afterTextChanged(Editable text) { + } + + @Override + public void beforeTextChanged(CharSequence text, int start, int count, int before) { + } + + private void setUpAuthenticationActionListener() { + getAuthenticationButton().setOnClickListener(this); + } + + @Override + public void onClick(View view) { + if (getUsername().trim().isEmpty() || getPassword().trim().isEmpty()) { + ViewDirector.of(this, R.id.animator_message).show(R.id.text_message_authentication_empty); + + if (getUsername().trim().isEmpty()) + getUsernameEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, android.R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); + if (getPassword().trim().isEmpty()) + getPasswordEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, android.R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); + + getUsernameEdit().addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + if (!getUsername().trim().isEmpty()) + getUsernameEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, R.color.blue_normal), PorterDuff.Mode.SRC_ATOP); + else + getUsernameEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); + } + + @Override + public void afterTextChanged(Editable editable) { + + } + }); + + getPasswordEdit().addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + if (!getPassword().trim().isEmpty()) + getPasswordEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, R.color.blue_normal), PorterDuff.Mode.SRC_ATOP); + else + getPasswordEdit().getBackground().setColorFilter(ContextCompat.getColor(AuthenticationActivity.this, android.R.color.holo_red_light), PorterDuff.Mode.SRC_ATOP); + } - @Override - public void afterTextChanged(Editable editable) { + @Override + public void afterTextChanged(Editable editable) { - } - }); + } + }); - } - else { - startAuthentication(); + } else { + startAuthentication(); - authenticate(); - } - } + authenticate(); + } + } - private void startAuthentication() { - hideAuthenticationText(); + private void startAuthentication() { + hideAuthenticationText(); - showProgress(); + showProgress(); - hideAuthenticationFailureMessage(); - } + hideAuthenticationFailureMessage(); + } - private void hideAuthenticationText() { - getUsernameEdit().setEnabled(false); - getPasswordEdit().setEnabled(false); - } + private void hideAuthenticationText() { + getUsernameEdit().setEnabled(false); + getPasswordEdit().setEnabled(false); + } - private void showProgress() { - ActionProcessButton authenticationButton = getAuthenticationButton(); + private void showProgress() { + ActionProcessButton authenticationButton = getAuthenticationButton(); - authenticationButton.setMode(ActionProcessButton.Mode.ENDLESS); - authenticationButton.setProgress(1); - } + authenticationButton.setMode(ActionProcessButton.Mode.ENDLESS); + authenticationButton.setProgress(1); + } - private void authenticate() { - amahiClient.authenticate(getUsername(), getPassword()); - } + private void authenticate() { + amahiClient.authenticate(getUsername(), getPassword()); + } - @Subscribe - public void onAuthenticationFailed(AuthenticationFailedEvent event) { - finishAuthentication(); + @Subscribe + public void onAuthenticationFailed(AuthenticationFailedEvent event) { + finishAuthentication(); - showAuthenticationFailureMessage(); - } + showAuthenticationFailureMessage(); + } - private void finishAuthentication() { - showAuthenticationText(); + private void finishAuthentication() { + showAuthenticationText(); - hideProgress(); - } + hideProgress(); + } - private void showAuthenticationText() { - getUsernameEdit().setEnabled(true); - getPasswordEdit().setEnabled(true); - } + private void showAuthenticationText() { + getUsernameEdit().setEnabled(true); + getPasswordEdit().setEnabled(true); + } - private void hideProgress() { - getAuthenticationButton().setProgress(0); - } + private void hideProgress() { + getAuthenticationButton().setProgress(0); + } - private void showAuthenticationFailureMessage() { - ViewDirector.of(this, R.id.animator_message).show(R.id.text_message_authentication); - } + private void showAuthenticationFailureMessage() { + ViewDirector.of(this, R.id.animator_message).show(R.id.text_message_authentication); + } - @Subscribe - public void onAuthenticationConnectionFailed(AuthenticationConnectionFailedEvent event) { - finishAuthentication(); + @Subscribe + public void onAuthenticationConnectionFailed(AuthenticationConnectionFailedEvent event) { + finishAuthentication(); - showAuthenticationConnectionFailureMessage(); - } + showAuthenticationConnectionFailureMessage(); + } - private void showAuthenticationConnectionFailureMessage() { - ViewDirector.of(this, R.id.animator_message).show(R.id.text_message_authentication_connection); - } + private void showAuthenticationConnectionFailureMessage() { + ViewDirector.of(this, R.id.animator_message).show(R.id.text_message_authentication_connection); + } - @Subscribe - public void onAuthenticationSucceed(AuthenticationSucceedEvent event) { - finishAuthentication(event.getAuthentication().getToken()); - } + @Subscribe + public void onAuthenticationSucceed(AuthenticationSucceedEvent event) { + finishAuthentication(event.getAuthentication().getToken()); + } - private void finishAuthentication(String authenticationToken) { - AccountManager accountManager = AccountManager.get(this); + private void finishAuthentication(String authenticationToken) { + AccountManager accountManager = AccountManager.get(this); - Bundle authenticationBundle = new Bundle(); + Bundle authenticationBundle = new Bundle(); - Account account = new AmahiAccount(getUsername()); + Account account = new AmahiAccount(getUsername()); - if (accountManager.addAccountExplicitly(account, getPassword(), null)) { - authenticationBundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); - authenticationBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); - authenticationBundle.putString(AccountManager.KEY_AUTHTOKEN, authenticationToken); + if (accountManager.addAccountExplicitly(account, getPassword(), null)) { + authenticationBundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); + authenticationBundle.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); + authenticationBundle.putString(AccountManager.KEY_AUTHTOKEN, authenticationToken); - accountManager.setAuthToken(account, account.type, authenticationToken); - } + accountManager.setAuthToken(account, account.type, authenticationToken); + } - setAccountAuthenticatorResult(authenticationBundle); + setAccountAuthenticatorResult(authenticationBundle); - setResult(Activity.RESULT_OK); + setResult(Activity.RESULT_OK); - finish(); - } + finish(); + } - @Override - protected void onResume() { - super.onResume(); + @Override + protected void onResume() { + super.onResume(); - BusProvider.getBus().register(this); - } + BusProvider.getBus().register(this); + } - @Override - protected void onPause() { - super.onPause(); + @Override + protected void onPause() { + super.onPause(); - BusProvider.getBus().unregister(this); - } + BusProvider.getBus().unregister(this); + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ExpandedControlsActivity.java b/src/main/java/org/amahi/anywhere/activity/ExpandedControlsActivity.java new file mode 100644 index 000000000..eb15ec857 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/activity/ExpandedControlsActivity.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.amahi.anywhere.activity; + +import android.view.Menu; + +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity; + +import org.amahi.anywhere.R; + +public class ExpandedControlsActivity extends ExpandedControllerActivity { + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.action_bar_expanded_controller, menu); + CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item); + return true; + } +} \ No newline at end of file diff --git a/src/main/java/org/amahi/anywhere/activity/IntroductionActivity.java b/src/main/java/org/amahi/anywhere/activity/IntroductionActivity.java new file mode 100644 index 000000000..e64d0de66 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/activity/IntroductionActivity.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.activity; + +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.content.ContextCompat; + +import com.github.paolorotolo.appintro.AppIntro; +import com.github.paolorotolo.appintro.AppIntroFragment; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.util.SampleSlide; + +public class IntroductionActivity extends AppIntro { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addSlide(SampleSlide.newInstance(R.layout.intro_first_layout)); + addSlide(AppIntroFragment.newInstance(getString(R.string.intro_title_2), getString(R.string.intro_desc_2), R.drawable.network, ContextCompat.getColor(this, R.color.intro_2))); + addSlide(AppIntroFragment.newInstance(getString(R.string.intro_title_3), getString(R.string.intro_desc_phone_3), R.drawable.photos, Color.DKGRAY)); + addSlide(AppIntroFragment.newInstance(getString(R.string.intro_title_4), getString(R.string.intro_desc_phone_4), R.drawable.music, ContextCompat.getColor(this, R.color.intro_4))); + addSlide(AppIntroFragment.newInstance(getString(R.string.intro_title_5), getString(R.string.intro_desc_phone_5), R.drawable.movies, ContextCompat.getColor(this, R.color.intro_5))); + addSlide(AppIntroFragment.newInstance(getString(R.string.intro_title_6), getString(R.string.intro_desc_6), R.drawable.tick, ContextCompat.getColor(this, R.color.intro_6))); + setFlowAnimation(); + } + + @Override + public void onDonePressed(Fragment currentFragment) { + super.onDonePressed(currentFragment); + launchTv(); + } + + @Override + public void onSkipPressed(Fragment currentFragment) { + super.onSkipPressed(currentFragment); + launchTv(); + } + + private void launchTv() { + startActivity(new Intent(this, NavigationActivity.class)); + } +} diff --git a/src/main/java/org/amahi/anywhere/activity/NativeVideoActivity.java b/src/main/java/org/amahi/anywhere/activity/NativeVideoActivity.java new file mode 100644 index 000000000..0be9cf09d --- /dev/null +++ b/src/main/java/org/amahi/anywhere/activity/NativeVideoActivity.java @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.activity; + +import android.content.Intent; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ProgressBar; +import android.widget.VideoView; + +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.cast.framework.media.RemoteMediaClient; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.util.FullScreenHelper; +import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.view.MediaControls; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import javax.inject.Inject; + +/** + * Native Video Player activity. Shows video, supports basic operations such as pausing, resuming. + * The playback itself is done via {@link org.amahi.anywhere.service}. + * Backed up by {@link android.media.MediaPlayer}. + */ +public class NativeVideoActivity extends AppCompatActivity implements + MediaPlayer.OnPreparedListener, + MediaPlayer.OnCompletionListener, + SessionManagerListener { + + private static final Set SUPPORTED_FORMATS; + private static final String VIDEO_POSITION = "video_position"; + + static { + SUPPORTED_FORMATS = new HashSet<>(Arrays.asList( + "video/3gp", + "video/mp4", + "video/ts", + "video/webm", + "video/x-matroska" + )); + } + + @Inject + ServerClient serverClient; + private MediaControls videoControls; + private VideoView videoView; + private CastContext mCastContext; + private CastSession mCastSession; + + public static boolean supports(String mime_type) { + return SUPPORTED_FORMATS.contains(mime_type); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_native_video); + + // NOTE-cpg: used for debugging - to visually display when the native player is being used + // Toast.makeText(this, "NATIVE PLAYER", Toast.LENGTH_SHORT).show(); + + setUpInjections(); + + setUpHomeNavigation(); + + setUpCast(); + + setUpVideo(); + + setUpFullScreen(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpCast() { + mCastContext = CastContext.getSharedInstance(this); + mCastSession = mCastContext.getSessionManager().getCurrentCastSession(); + } + + private void setUpVideo() { + if (mCastSession != null && mCastSession.isConnected()) { + loadRemoteMedia(0, true); + finish(); + } else { + setUpVideoTitle(); + setUpVideoView(); + startVideo(); + } + } + + private ServerShare getVideoShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private ServerFile getVideoFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private Uri getVideoUri() { + return serverClient.getFileUri(getVideoShare(), getVideoFile()); + } + + private FrameLayout getVideoMainFrame() { + return (FrameLayout) findViewById(R.id.video_main_frame); + } + + private View getControlsContainer() { + return findViewById(R.id.container_controls); + } + + private ProgressBar getProgressBar() { + return (ProgressBar) findViewById(android.R.id.progress); + } + + private void setUpVideoView() { + videoView = (VideoView) findViewById(R.id.video_view); + setUpVideoControls(); + videoView.setOnPreparedListener(this); + videoView.setOnCompletionListener(this); + videoView.setVideoURI(getVideoUri()); + videoView.setMediaController(videoControls); + } + + private void startVideo() { + Bundle bundle = getIntent().getExtras(); + boolean shouldStartPlayback = bundle.getBoolean("shouldStart", true); + int startPosition = bundle.getInt("startPosition", 0); + if (startPosition > 0) { + videoView.seekTo(startPosition); + } + if (shouldStartPlayback) { + videoView.start(); + } + } + + private boolean areVideoControlsAvailable() { + return videoControls != null; + } + + private void setUpVideoControls() { + if (!areVideoControlsAvailable()) { + videoControls = new MediaControls(this); + } + } + + private void setUpVideoTitle() { + getSupportActionBar().setTitle(getVideoFile().getName()); + } + + private void setUpFullScreen() { + final FullScreenHelper fullScreen = new FullScreenHelper(getSupportActionBar(), getVideoMainFrame()); + fullScreen.init(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.action_bar_cast_button, menu); + CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, + R.id.media_route_menu_item); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + protected void onResume() { + mCastContext.getSessionManager().addSessionManagerListener(this, CastSession.class); + super.onResume(); + } + + @Override + public void onPause() { + super.onPause(); + + mCastContext.getSessionManager().removeSessionManagerListener(this, CastSession.class); + + if (videoControls != null && videoControls.isShowing()) { + videoControls.hide(); + } + + if (videoView != null) { + if (!isChangingConfigurations()) { + videoView.pause(); + } + + if (isFinishing()) { + videoView.stopPlayback(); + } + } + } + + @Override + public void onPrepared(MediaPlayer mp) { + getProgressBar().setVisibility(View.GONE); + getVideoMainFrame().setVisibility(View.VISIBLE); + videoControls.setAnchorView(getControlsContainer()); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + if (videoView != null) { + outState.putInt(VIDEO_POSITION, videoView.getCurrentPosition()); + } + super.onSaveInstanceState(outState); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + int videoPosition = savedInstanceState.getInt(VIDEO_POSITION, 0); + if (videoPosition > 0 && videoView != null) { + videoView.seekTo(videoPosition); + } + super.onRestoreInstanceState(savedInstanceState); + } + + @Override + public void onCompletion(MediaPlayer mp) { + finish(); + } + + @Override + public void onSessionEnded(CastSession session, int error) { + } + + @Override + public void onSessionResumed(CastSession session, boolean wasSuspended) { + onApplicationConnected(session); + } + + @Override + public void onSessionResumeFailed(CastSession session, int error) { + } + + @Override + public void onSessionStarted(CastSession session, String sessionId) { + onApplicationConnected(session); + } + + @Override + public void onSessionStartFailed(CastSession session, int error) { + } + + @Override + public void onSessionStarting(CastSession session) { + } + + @Override + public void onSessionEnding(CastSession session) { + } + + @Override + public void onSessionResuming(CastSession session, String sessionId) { + } + + @Override + public void onSessionSuspended(CastSession session, int reason) { + } + + private void onApplicationConnected(CastSession castSession) { + mCastSession = castSession; + boolean isVideoPlaying = videoView.isPlaying(); + if (isVideoPlaying) + videoView.pause(); + loadRemoteMedia(videoView.getCurrentPosition(), isVideoPlaying); + finish(); + } + + private void loadRemoteMedia(int position, boolean autoPlay) { + if (mCastSession == null) { + return; + } + final RemoteMediaClient remoteMediaClient = mCastSession.getRemoteMediaClient(); + if (remoteMediaClient == null) { + return; + } + remoteMediaClient.addListener(new RemoteMediaClient.Listener() { + @Override + public void onStatusUpdated() { + Intent intent = new Intent(NativeVideoActivity.this, ExpandedControlsActivity.class); + startActivity(intent); + remoteMediaClient.removeListener(this); + } + + @Override + public void onMetadataUpdated() { + } + + @Override + public void onQueueStatusUpdated() { + } + + @Override + public void onPreloadStatusUpdated() { + } + + @Override + public void onSendingRemoteMediaRequest() { + } + + @Override + public void onAdBreakStatusUpdated() { + + } + }); + remoteMediaClient.load(buildMediaInfo(), autoPlay, position); + } + + private MediaInfo buildMediaInfo() { + MediaMetadata movieMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE); + + movieMetadata.putString(MediaMetadata.KEY_TITLE, getVideoFile().getNameOnly()); + + MediaInfo.Builder builder = new MediaInfo.Builder(getVideoUri().toString()) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(getVideoFile().getMime()) + .setMetadata(movieMetadata); + if (videoView != null) { + builder.setStreamDuration(videoView.getDuration()); + } + return builder.build(); + } +} diff --git a/src/main/java/org/amahi/anywhere/activity/NavigationActivity.java b/src/main/java/org/amahi/anywhere/activity/NavigationActivity.java index b0ced41bd..7e3064e72 100644 --- a/src/main/java/org/amahi/anywhere/activity/NavigationActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/NavigationActivity.java @@ -19,16 +19,19 @@ package org.amahi.anywhere.activity; -import android.support.v4.app.Fragment; import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; +import android.support.v4.app.Fragment; import android.support.v4.widget.DrawerLayout; import android.support.v7.app.ActionBarDrawerToggle; import android.support.v7.app.AppCompatActivity; import android.view.Gravity; import android.view.MenuItem; import android.view.View; +import android.view.ViewStub; +import android.widget.FrameLayout; +import android.widget.RelativeLayout; import com.squareup.otto.Subscribe; @@ -43,9 +46,12 @@ import org.amahi.anywhere.server.client.ServerClient; import org.amahi.anywhere.server.model.ServerApp; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.activity.IntroActivity; import org.amahi.anywhere.util.Android; +import org.amahi.anywhere.util.CheckTV; import org.amahi.anywhere.util.Fragments; import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.util.Preferences; import javax.inject.Inject; @@ -56,282 +62,337 @@ * The navigation itself is done via {@link org.amahi.anywhere.fragment.NavigationFragment}, * {@link org.amahi.anywhere.fragment.ServerSharesFragment} and {@link org.amahi.anywhere.fragment.ServerAppsFragment}. */ -public class NavigationActivity extends AppCompatActivity implements DrawerLayout.DrawerListener -{ - private static final class State - { - private State() { - } +public class NavigationActivity extends AppCompatActivity implements DrawerLayout.DrawerListener { + @Inject + ServerClient serverClient; + private ActionBarDrawerToggle navigationDrawerToggle; + private String navigationTitle; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_navigation); + + if (CheckTV.isATV(this)) { + handleTvFirstRun(); + showTvLoading(); + } + + setUpInjections(); + + setUpHomeNavigation(); + + setUpNavigation(savedInstanceState); + } + + private void handleTvFirstRun() { + Boolean isFirstRun = Preferences.getFirstRun(this); + + if (isFirstRun) { + startActivity(new Intent(this, IntroActivity.class)); + Preferences.setFirstRun(this); + } + } + + private void showTvLoading() { + + inflateStubs(); + + hideMobileContainers(); + + hideActionBar(); + + setUpNavigationFragment(); + + setUpShares(); + } + + private void inflateStubs() { + ViewStub tvLoadingStub = (ViewStub) findViewById(R.id.view_stub_tv_loading); + tvLoadingStub.inflate(); + } + + private void hideMobileContainers() { + RelativeLayout tvLoading = (RelativeLayout) findViewById(R.id.tv_loading); + + getContainerContent().setVisibility(View.INVISIBLE); + + getContainerNavigation().setVisibility(View.INVISIBLE); + + displayTvLoading(tvLoading); + } + + private FrameLayout getContainerContent() { + return (FrameLayout) findViewById(R.id.container_content); + } + + private FrameLayout getContainerNavigation() { + return (FrameLayout) findViewById(R.id.container_navigation); + } + + private void displayTvLoading(RelativeLayout tvLoading) { + tvLoading.setVisibility(View.VISIBLE); + } + + private void hideActionBar() { + getSupportActionBar().hide(); + } - public static final String NAVIGATION_TITLE = "navigation_title"; - public static final String NAVIGATION_DRAWER_VISIBLE = "navigation_drawer_visible"; - } + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } - @Inject - ServerClient serverClient; + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(isNavigationDrawerAvailable()); + getSupportActionBar().setDisplayHomeAsUpEnabled(isNavigationDrawerAvailable()); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } - private ActionBarDrawerToggle navigationDrawerToggle; - private String navigationTitle; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_navigation); + private boolean isNavigationDrawerAvailable() { + return !Android.isTablet(this); + } - setUpInjections(); - - setUpHomeNavigation(); + private void setUpNavigation(Bundle state) { + if (isNavigationDrawerAvailable()) { + setUpNavigationDrawer(); + } - setUpNavigation(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpHomeNavigation() { - getSupportActionBar().setHomeButtonEnabled(isNavigationDrawerAvailable()); - getSupportActionBar().setDisplayHomeAsUpEnabled(isNavigationDrawerAvailable()); - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } + if (!CheckTV.isATV(this)) setUpNavigationFragment(); - private boolean isNavigationDrawerAvailable() { - return !Android.isTablet(this); - } + if (isNavigationDrawerAvailable() && isNavigationDrawerRequired(state)) { + showNavigationDrawer(); + } - private void setUpNavigation(Bundle state) { - if (isNavigationDrawerAvailable()) { - setUpNavigationDrawer(); - } + setUpNavigationTitle(state); + } - setUpNavigationFragment(); + private void setUpNavigationDrawer() { + this.navigationDrawerToggle = buildNavigationDrawerToggle(); - if (isNavigationDrawerAvailable() && isNavigationDrawerRequired(state)) { - showNavigationDrawer(); - } + getDrawer().addDrawerListener(this); + getDrawer().setDrawerShadow(R.drawable.bg_shadow_drawer, Gravity.START); + } - setUpNavigationTitle(state); - } + private ActionBarDrawerToggle buildNavigationDrawerToggle() { + return new ActionBarDrawerToggle(this, getDrawer(), R.string.menu_navigation_open, R.string.menu_navigation_close); + } - private void setUpNavigationDrawer() { - this.navigationDrawerToggle = buildNavigationDrawerToggle(); - - getDrawer().addDrawerListener(this); - getDrawer().setDrawerShadow(R.drawable.bg_shadow_drawer, Gravity.START); - } - - private ActionBarDrawerToggle buildNavigationDrawerToggle() { - return new ActionBarDrawerToggle(this,getDrawer(),R.string.menu_navigation_open,R.string.menu_navigation_close); - } - - private DrawerLayout getDrawer() { - return (DrawerLayout) findViewById(R.id.drawer_content); - } - - @Override - public void onDrawerOpened(View drawer) { - navigationDrawerToggle.onDrawerOpened(drawer); - - setUpTitle(getString(R.string.application_name)); - } + private DrawerLayout getDrawer() { + return (DrawerLayout) findViewById(R.id.drawer_content); + } - private void setUpTitle(String title) { - if (isNavigationDrawerAvailable()) { - getSupportActionBar().setTitle(title); - } - } + @Override + public void onDrawerOpened(View drawer) { + navigationDrawerToggle.onDrawerOpened(drawer); - @Override - public void onDrawerClosed(View drawer) { - navigationDrawerToggle.onDrawerClosed(drawer); + setUpTitle(getString(R.string.application_name)); + } - setUpTitle(); - } + private void setUpTitle(String title) { + if (isNavigationDrawerAvailable()) { + getSupportActionBar().setTitle(title); + } + } - @Override - public void onDrawerSlide(View drawer, float slideOffset) { - navigationDrawerToggle.onDrawerSlide(drawer, slideOffset); - } + @Override + public void onDrawerClosed(View drawer) { + navigationDrawerToggle.onDrawerClosed(drawer); - @Override - public void onDrawerStateChanged(int state) { - navigationDrawerToggle.onDrawerStateChanged(state); - } + setUpTitle(); + } - private void setUpNavigationTitle(Bundle state) { - this.navigationTitle = getNavigationTitle(state); + @Override + public void onDrawerSlide(View drawer, float slideOffset) { + navigationDrawerToggle.onDrawerSlide(drawer, slideOffset); + } - if (isNavigationDrawerAvailable() && !isNavigationDrawerOpen()) { - setUpTitle(); - } - } + @Override + public void onDrawerStateChanged(int state) { + navigationDrawerToggle.onDrawerStateChanged(state); + } - private String getNavigationTitle(Bundle state) { - if (isNavigationStateValid(state)) { - return state.getString(State.NAVIGATION_TITLE); - } else { - return getString(R.string.application_name); - } - } - - private boolean isNavigationStateValid(Bundle state) { - return (state != null) && state.containsKey(State.NAVIGATION_TITLE); - } + private void setUpNavigationTitle(Bundle state) { + this.navigationTitle = getNavigationTitle(state); - private void setUpNavigationFragment() { - Fragments.Operator.at(this).set(buildNavigationFragment(), R.id.container_navigation); - } - - private Fragment buildNavigationFragment() { - return Fragments.Builder.buildNavigationFragment(); - } - - private boolean isNavigationDrawerRequired(Bundle state) { - return (state == null) || state.getBoolean(State.NAVIGATION_DRAWER_VISIBLE); - } - - private void showNavigationDrawer() { - getDrawer().openDrawer(findViewById(R.id.container_navigation)); - } - - @Subscribe - public void onSharesSelected(SharesSelectedEvent event) { - this.navigationTitle = getString(R.string.title_shares); - - if (isNavigationDrawerAvailable()) { - setUpTitle(); - } - - setUpShares(); - - if (isNavigationDrawerAvailable()) { - hideNavigationDrawer(); - } - } - - private void setUpTitle() { - setUpTitle(navigationTitle); - } - - private void setUpShares() { - Fragments.Operator.at(this).replace(buildSharesFragment(), R.id.container_content); - } - - private Fragment buildSharesFragment() { - return Fragments.Builder.buildServerSharesFragment(); - } - - private void hideNavigationDrawer() { - getDrawer().closeDrawers(); - } - - @Subscribe - public void onAppsSelected(AppsSelectedEvent event) { - this.navigationTitle = getString(R.string.title_apps); - - if (isNavigationDrawerAvailable()) { - setUpTitle(); - } - - setUpApps(); - - if (isNavigationDrawerAvailable()) { - hideNavigationDrawer(); - } - } - - private void setUpApps() { - Fragments.Operator.at(this).replace(buildAppsFragment(), R.id.container_content); - } - - private Fragment buildAppsFragment() { - return Fragments.Builder.buildServerAppsFragment(); - } - - @Subscribe - public void onShareSelected(ShareSelectedEvent event) { - setUpShare(event.getShare()); - } - - private void setUpShare(ServerShare share) { - Intent intent = Intents.Builder.with(this).buildServerFilesActivity(share); - startActivity(intent); - } - - @Subscribe - public void onAppSelected(AppSelectedEvent event) { - setUpApp(event.getApp()); - } - - private void setUpApp(ServerApp app) { - Intent intent = Intents.Builder.with(this).buildServerAppAcitivity(app); - startActivity(intent); - } - - @Subscribe - public void onSettingsSelected(SettingsSelectedEvent event) { - setUpSettings(); - } - - private void setUpSettings() { - Intent intent = Intents.Builder.with(this).buildSettingsIntent(); - startActivity(intent); - } - - @Override - protected void onPostCreate(Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - - if (isNavigationDrawerAvailable()) { - navigationDrawerToggle.syncState(); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - if (isNavigationDrawerAvailable() && navigationDrawerToggle.onOptionsItemSelected(menuItem)) { - return true; - } - - return super.onOptionsItemSelected(menuItem); - } - - @Override - public void onConfigurationChanged(Configuration configuration) { - super.onConfigurationChanged(configuration); - - if (isNavigationDrawerAvailable()) { - navigationDrawerToggle.onConfigurationChanged(configuration); - } - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - tearDownNavigationState(outState); - } - - private void tearDownNavigationState(Bundle state) { - state.putString(State.NAVIGATION_TITLE, navigationTitle); - state.putBoolean(State.NAVIGATION_DRAWER_VISIBLE, isNavigationDrawerAvailable() && isNavigationDrawerOpen()); - } - - private boolean isNavigationDrawerOpen() { - return getDrawer().isDrawerOpen(findViewById(R.id.container_navigation)); - } - - @Override - protected void onResume() { - super.onResume(); - - BusProvider.getBus().register(this); - } - - @Override - protected void onPause() { - super.onPause(); - - BusProvider.getBus().unregister(this); - } + if (isNavigationDrawerAvailable() && !isNavigationDrawerOpen()) { + setUpTitle(); + } + } + + private String getNavigationTitle(Bundle state) { + if (isNavigationStateValid(state)) { + return state.getString(State.NAVIGATION_TITLE); + } else { + return getString(R.string.application_name); + } + } + + private boolean isNavigationStateValid(Bundle state) { + return (state != null) && state.containsKey(State.NAVIGATION_TITLE); + } + + private void setUpNavigationFragment() { + Fragments.Operator.at(this).set(buildNavigationFragment(), R.id.container_navigation); + } + + private Fragment buildNavigationFragment() { + return Fragments.Builder.buildNavigationFragment(); + } + + private boolean isNavigationDrawerRequired(Bundle state) { + return (state == null) || state.getBoolean(State.NAVIGATION_DRAWER_VISIBLE); + } + + private void showNavigationDrawer() { + if (!CheckTV.isATV(this)) + getDrawer().openDrawer(findViewById(R.id.container_navigation)); + } + + @Subscribe + public void onSharesSelected(SharesSelectedEvent event) { + this.navigationTitle = getString(R.string.title_shares); + + if (isNavigationDrawerAvailable()) { + setUpTitle(); + } + + setUpShares(); + + if (isNavigationDrawerAvailable()) { + hideNavigationDrawer(); + } + } + + private void setUpTitle() { + setUpTitle(navigationTitle); + } + + private void setUpShares() { + Fragments.Operator.at(this).replace(buildSharesFragment(), R.id.container_content); + } + + private Fragment buildSharesFragment() { + return Fragments.Builder.buildServerSharesFragment(); + } + + private void hideNavigationDrawer() { + getDrawer().closeDrawers(); + } + + @Subscribe + public void onAppsSelected(AppsSelectedEvent event) { + this.navigationTitle = getString(R.string.title_apps); + + if (isNavigationDrawerAvailable()) { + setUpTitle(); + } + + setUpApps(); + + if (isNavigationDrawerAvailable()) { + hideNavigationDrawer(); + } + } + + private void setUpApps() { + Fragments.Operator.at(this).replace(buildAppsFragment(), R.id.container_content); + } + + private Fragment buildAppsFragment() { + return Fragments.Builder.buildServerAppsFragment(); + } + + @Subscribe + public void onShareSelected(ShareSelectedEvent event) { + setUpShare(event.getShare()); + } + + private void setUpShare(ServerShare share) { + Intent intent = Intents.Builder.with(this).buildServerFilesActivity(share); + startActivity(intent); + } + + @Subscribe + public void onAppSelected(AppSelectedEvent event) { + setUpApp(event.getApp()); + } + + private void setUpApp(ServerApp app) { + Intent intent = Intents.Builder.with(this).buildServerAppAcitivity(app); + startActivity(intent); + } + + @Subscribe + public void onSettingsSelected(SettingsSelectedEvent event) { + setUpSettings(); + } + + private void setUpSettings() { + Intent intent = Intents.Builder.with(this).buildSettingsIntent(); + startActivity(intent); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + + if (isNavigationDrawerAvailable()) { + navigationDrawerToggle.syncState(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + if (isNavigationDrawerAvailable() && navigationDrawerToggle.onOptionsItemSelected(menuItem)) { + return true; + } + + return super.onOptionsItemSelected(menuItem); + } + + @Override + public void onConfigurationChanged(Configuration configuration) { + super.onConfigurationChanged(configuration); + + if (isNavigationDrawerAvailable()) { + navigationDrawerToggle.onConfigurationChanged(configuration); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + tearDownNavigationState(outState); + } + + private void tearDownNavigationState(Bundle state) { + state.putString(State.NAVIGATION_TITLE, navigationTitle); + state.putBoolean(State.NAVIGATION_DRAWER_VISIBLE, isNavigationDrawerAvailable() && isNavigationDrawerOpen()); + } + + private boolean isNavigationDrawerOpen() { + return getDrawer().isDrawerOpen(findViewById(R.id.container_navigation)); + } + + @Override + protected void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + protected void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + private static final class State { + public static final String NAVIGATION_TITLE = "navigation_title"; + public static final String NAVIGATION_DRAWER_VISIBLE = "navigation_drawer_visible"; + private State() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ServerAppActivity.java b/src/main/java/org/amahi/anywhere/activity/ServerAppActivity.java index dc6ec00ee..9e20bfc99 100644 --- a/src/main/java/org/amahi/anywhere/activity/ServerAppActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/ServerAppActivity.java @@ -48,213 +48,210 @@ * App activity. Shows web apps contents and allows basic navigation inside them. * Backed up by {@link android.webkit.WebView}. */ -public class ServerAppActivity extends AppCompatActivity -{ - private static final class AppWebAgentField - { - private AppWebAgentField() { - } +public class ServerAppActivity extends AppCompatActivity { + @Inject + ServerClient serverClient; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_app); + + setUpInjections(); + + setUpApp(savedInstanceState); + + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpApp(Bundle state) { + setUpAppWebCookie(); + setUpAppWebAgent(); + setUpAppWebClient(); + setUpAppWebSettings(); + setUpAppWebTitle(); + setUpAppWebContent(state); + } + + private void setUpAppWebCookie() { + String appHost = getApp().getHost(); + String appCookies = Preferences.ofCookie(this).getAppCookies(appHost); + + for (String appCookie : TextUtils.split(appCookies, ";")) { + CookieManager.getInstance().setCookie(getAppUrl(), appCookie); + } + } + + private ServerApp getApp() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_APP); + } + + private String getAppUrl() { + return serverClient.getServerAddress(); + } + + private void setUpAppWebAgent() { + getWebView().getSettings().setUserAgentString(getAppWebAgent()); + } + + private WebView getWebView() { + return (WebView) findViewById(R.id.web_content); + } - public static final String HOST = "Vhost"; - } + private String getAppWebAgent() { + Map agentFields = new HashMap(); + agentFields.put(AppWebAgentField.HOST, getApp().getHost()); + + return Identifier.getUserAgent(this, agentFields); + } + + private void setUpAppWebClient() { + getWebView().setWebViewClient(new AppWebClient(this)); + } - @Inject - ServerClient serverClient; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_server_app); - - setUpInjections(); - - setUpApp(savedInstanceState); - - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpApp(Bundle state) { - setUpAppWebCookie(); - setUpAppWebAgent(); - setUpAppWebClient(); - setUpAppWebSettings(); - setUpAppWebTitle(); - setUpAppWebContent(state); - } - - private void setUpAppWebCookie() { - String appHost = getApp().getHost(); - String appCookies = Preferences.ofCookie(this).getAppCookies(appHost); - - for (String appCookie : TextUtils.split(appCookies, ";")) { - CookieManager.getInstance().setCookie(getAppUrl(), appCookie); - } - } - - private ServerApp getApp() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_APP); - } - - private String getAppUrl() { - return serverClient.getServerAddress(); - } - - private void setUpAppWebAgent() { - getWebView().getSettings().setUserAgentString(getAppWebAgent()); - } - - private WebView getWebView() { - return (WebView) findViewById(R.id.web_content); - } + private void setUpAppWebSettings() { + WebSettings settings = getWebView().getSettings(); + + settings.setJavaScriptEnabled(true); - private String getAppWebAgent() { - Map agentFields = new HashMap(); - agentFields.put(AppWebAgentField.HOST, getApp().getHost()); - - return Identifier.getUserAgent(this, agentFields); - } - - private void setUpAppWebClient() { - getWebView().setWebViewClient(new AppWebClient(this)); - } + settings.setUseWideViewPort(true); + + settings.setSupportZoom(true); + settings.setBuiltInZoomControls(true); + settings.setDisplayZoomControls(false); + } + + private void setUpAppWebTitle() { + getSupportActionBar().setTitle(getApp().getName()); + } - private void setUpAppWebSettings() { - WebSettings settings = getWebView().getSettings(); - - settings.setJavaScriptEnabled(true); + private void setUpAppWebContent(Bundle state) { + if (state == null) { + getWebView().loadUrl(getAppUrl()); + } + } - settings.setUseWideViewPort(true); - - settings.setSupportZoom(true); - settings.setBuiltInZoomControls(true); - settings.setDisplayZoomControls(false); - } - - private void setUpAppWebTitle() { - getSupportActionBar().setTitle(getApp().getName()); - } - - private void setUpAppWebContent(Bundle state) { - if (state == null) { - getWebView().loadUrl(getAppUrl()); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.action_bar_server_app, menu); - - return super.onCreateOptionsMenu(menu); - } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.action_bar_server_app, menu); - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case android.R.id.home: - finish(); - return true; + return super.onCreateOptionsMenu(menu); + } - case R.id.menu_back: - if (getWebView().canGoBack()) { - getWebView().goBack(); - } - return true; + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + return true; - case R.id.menu_forward: - if (getWebView().canGoForward()) { - getWebView().goForward(); - } - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - - getWebView().restoreState(savedInstanceState); - } - - @Override - protected void onResume() { - super.onResume(); + case R.id.menu_back: + if (getWebView().canGoBack()) { + getWebView().goBack(); + } + return true; - getWebView().onResume(); - getWebView().resumeTimers(); - } + case R.id.menu_forward: + if (getWebView().canGoForward()) { + getWebView().goForward(); + } + return true; - @Override - protected void onPause() { - super.onPause(); + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + + getWebView().restoreState(savedInstanceState); + } - getWebView().onPause(); - getWebView().pauseTimers(); - } + @Override + protected void onResume() { + super.onResume(); - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); + getWebView().onResume(); + getWebView().resumeTimers(); + } - getWebView().saveState(outState); - } + @Override + protected void onPause() { + super.onPause(); - @Override - protected void onDestroy() { - super.onDestroy(); + getWebView().onPause(); + getWebView().pauseTimers(); + } - getWebView().destroy(); + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); - if (isFinishing()) { - tearDownAppWebCookie(); - } - } + getWebView().saveState(outState); + } - private void tearDownAppWebCookie() { - String appHost = getApp().getHost(); - String appCookies = CookieManager.getInstance().getCookie(getAppUrl()); + @Override + protected void onDestroy() { + super.onDestroy(); - Preferences.ofCookie(this).setAppCookies(appHost, appCookies); + getWebView().destroy(); - if (CookieManager.getInstance().hasCookies()) { - CookieManager.getInstance().removeAllCookie(); - } - } + if (isFinishing()) { + tearDownAppWebCookie(); + } + } - private static final class AppWebClient extends WebViewClient - { - private final ServerAppActivity activity; - - public AppWebClient(ServerAppActivity activity) { - this.activity = activity; - } - - @Override - public void onPageStarted(WebView appWebView, String appUrl, Bitmap appFavicon) { - super.onPageStarted(appWebView, appUrl, appFavicon); - - activity.showProgress(); - } - - @Override - public void onPageFinished(WebView appWebView, String appUrl) { - super.onPageFinished(appWebView, appUrl); - - activity.showApp(); - } - } - - private void showProgress() { - ViewDirector.of(this, R.id.animator).show(android.R.id.progress); - } - - private void showApp() { - ViewDirector.of(this, R.id.animator).show(R.id.web_content); - } + private void tearDownAppWebCookie() { + String appHost = getApp().getHost(); + String appCookies = CookieManager.getInstance().getCookie(getAppUrl()); + + Preferences.ofCookie(this).setAppCookies(appHost, appCookies); + + if (CookieManager.getInstance().hasCookies()) { + CookieManager.getInstance().removeAllCookie(); + } + } + + private void showProgress() { + ViewDirector.of(this, R.id.animator).show(android.R.id.progress); + } + + private void showApp() { + ViewDirector.of(this, R.id.animator).show(R.id.web_content); + } + + private static final class AppWebAgentField { + public static final String HOST = "Vhost"; + + private AppWebAgentField() { + } + } + + private static final class AppWebClient extends WebViewClient { + private final ServerAppActivity activity; + + public AppWebClient(ServerAppActivity activity) { + this.activity = activity; + } + + @Override + public void onPageStarted(WebView appWebView, String appUrl, Bitmap appFavicon) { + super.onPageStarted(appWebView, appUrl, appFavicon); + + activity.showProgress(); + } + + @Override + public void onPageFinished(WebView appWebView, String appUrl) { + super.onPageFinished(appWebView, appUrl); + + activity.showApp(); + } + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ServerFileAudioActivity.java b/src/main/java/org/amahi/anywhere/activity/ServerFileAudioActivity.java index 24fa0a762..438a4d534 100644 --- a/src/main/java/org/amahi/anywhere/activity/ServerFileAudioActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/ServerFileAudioActivity.java @@ -25,16 +25,25 @@ import android.content.ServiceConnection; import android.graphics.Bitmap; import android.graphics.BitmapFactory; -import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; import android.os.Bundle; import android.os.IBinder; import android.support.v7.app.AppCompatActivity; +import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.ImageView; import android.widget.MediaController; import android.widget.TextView; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaLoadOptions; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.cast.framework.media.RemoteMediaClient; import com.squareup.otto.Subscribe; import org.amahi.anywhere.AmahiApplication; @@ -49,10 +58,11 @@ import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; import org.amahi.anywhere.service.AudioService; +import org.amahi.anywhere.task.AudioMetadataRetrievingTask; import org.amahi.anywhere.util.AudioMetadataFormatter; import org.amahi.anywhere.util.Intents; import org.amahi.anywhere.util.ViewDirector; -import org.amahi.anywhere.view.MediaControls; +import org.amahi.anywhere.view.AudioControls; import java.util.ArrayList; import java.util.Arrays; @@ -67,474 +77,601 @@ * The playback itself is done via {@link org.amahi.anywhere.service.AudioService}. * Backed up by {@link android.media.MediaPlayer}. */ -public class ServerFileAudioActivity extends AppCompatActivity implements ServiceConnection, MediaController.MediaPlayerControl -{ - private static final Set SUPPORTED_FORMATS; - - static { - SUPPORTED_FORMATS = new HashSet(Arrays.asList( - "audio/flac", - "audio/mp4", - "audio/mpeg", - "audio/ogg" - )); - } - - public static boolean supports(String mime_type) { - return SUPPORTED_FORMATS.contains(mime_type); - } - - private static final class State - { - private State() { - } - - public static final String AUDIO_TITLE = "audio_title"; - public static final String AUDIO_SUBTITLE = "audio_subtitle"; - public static final String AUDIO_ALBUM_ART = "audio_album_art"; - } - - @Inject - ServerClient serverClient; - - private AudioService audioService; - private MediaControls audioControls; - - private ServerFile audioFile; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_server_file_audio); - - setUpInjections(); - - setUpHomeNavigation(); - - setUpAudio(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpHomeNavigation() { - getSupportActionBar().setHomeButtonEnabled(true); - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } - - private void setUpAudio(Bundle state) { - setUpAudioFile(); - setUpAudioTitle(); - setUpAudioMetadata(state); - } - - private void setUpAudioFile() { - this.audioFile = getFile(); - } - - private ServerFile getFile() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); - } - - private void setUpAudioTitle() { - getSupportActionBar().setTitle(audioFile.getName()); - } - - private void setUpAudioMetadata(Bundle state) { - if (isAudioMetadataStateValid(state)) { - setUpAudioMetadataState(state); - - showAudioMetadata(); - } - } - - private boolean isAudioMetadataStateValid(Bundle state) { - return (state != null) && state.containsKey(State.AUDIO_TITLE); - } - - private void setUpAudioMetadataState(Bundle state) { - getAudioTitleView().setText(state.getString(State.AUDIO_TITLE)); - getAudioSubtitleView().setText(state.getString(State.AUDIO_SUBTITLE)); - getAudioAlbumArtView().setImageBitmap((Bitmap) state.getParcelable(State.AUDIO_ALBUM_ART)); - } - - private TextView getAudioTitleView() { - return (TextView) findViewById(R.id.text_title); - } - - private TextView getAudioSubtitleView() { - return (TextView) findViewById(R.id.text_subtitle); - } - - private ImageView getAudioAlbumArtView() { - return (ImageView) findViewById(R.id.image_album_art); - } - - private void showAudioMetadata() { - ViewDirector.of(this, R.id.animator).show(R.id.layout_content); - } - - @Subscribe - public void onAudioMetadataRetrieved(AudioMetadataRetrievedEvent event) { - AudioMetadataFormatter audioMetadataFormatter = new AudioMetadataFormatter( - event.getAudioTitle(), event.getAudioArtist(), event.getAudioAlbum()); - - setUpAudioMetadata(audioMetadataFormatter, event.getAudioAlbumArt()); - } - - private void setUpAudioMetadata(AudioMetadataFormatter audioMetadataFormatter, Bitmap audioAlbumArt) { - getAudioTitleView().setText(audioMetadataFormatter.getAudioTitle(audioFile)); - getAudioSubtitleView().setText(audioMetadataFormatter.getAudioSubtitle(getShare())); - if (audioAlbumArt == null) { - audioAlbumArt = BitmapFactory.decodeResource(getResources(), R.drawable.default_audiotrack); - getAudioAlbumArtView().setScaleType(ImageView.ScaleType.CENTER_INSIDE); - } else { - getAudioAlbumArtView().setScaleType(ImageView.ScaleType.CENTER_CROP); - } - getAudioAlbumArtView().setImageBitmap(audioAlbumArt); - } - - private ServerShare getShare() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); - } - - @Override - protected void onStart() { - super.onStart(); - - setUpAudioService(); - setUpAudioServiceBind(); - } - - private void setUpAudioService() { - Intent intent = new Intent(this, AudioService.class); - startService(intent); - } - - private void setUpAudioServiceBind() { - Intent intent = new Intent(this, AudioService.class); - bindService(intent, this, Context.BIND_AUTO_CREATE); - } - - @Override - public void onServiceDisconnected(ComponentName serviceName) { - } - - @Override - public void onServiceConnected(ComponentName serviceName, IBinder serviceBinder) { - setUpAudioServiceBind(serviceBinder); - - setUpAudioControls(); - setUpAudioPlayback(); - } - - private void setUpAudioServiceBind(IBinder serviceBinder) { - AudioService.AudioServiceBinder audioServiceBinder = (AudioService.AudioServiceBinder) serviceBinder; - audioService = audioServiceBinder.getAudioService(); - } - - private void setUpAudioControls() { - if (!areAudioControlsAvailable()) { - audioControls = new MediaControls(this); - - audioControls.setMediaPlayer(this); - audioControls.setPrevNextListeners(new AudioControlsNextListener(), new AudioControlsPreviousListener()); - audioControls.setAnchorView(findViewById(R.id.animator)); - } - } - - private boolean areAudioControlsAvailable() { - return audioControls != null; - } - - private void setUpAudioPlayback() { - if (audioService.isAudioStarted()) { - showAudio(); - } else { - audioService.startAudio(getShare(), getAudioFiles(), getFile()); - } - } - - private List getAudioFiles() { - List audioFiles = new ArrayList(); - - for (ServerFile file : getFiles()) { - if (SUPPORTED_FORMATS.contains(file.getMime())) { - audioFiles.add(file); - } - } - - return audioFiles; - } - - private List getFiles() { - return getIntent().getParcelableArrayListExtra(Intents.Extras.SERVER_FILES); - } - - @Subscribe - public void onAudioPrepared(AudioPreparedEvent event) { - this.audioFile = audioService.getAudioFile(); - - start(); - - setUpAudioTitle(); - - showAudio(); - } - - private void showAudio() { - showAudioMetadata(); - showAudioControls(); - } - - private void showAudioControls() { - if (areAudioControlsAvailable() && !audioControls.isShowing()) { - audioControls.showAnimated(); - } - } - - @Subscribe - public void onNextAudio(AudioControlNextEvent event) { - tearDownAudioTitle(); - tearDownAudioMetadata(); - - hideAudio(); - } - - @Subscribe - public void onPreviousAudio(AudioControlPreviousEvent event) { - tearDownAudioTitle(); - tearDownAudioMetadata(); - - hideAudio(); - } - - @Subscribe - public void onAudioCompleted(AudioCompletedEvent event) { - tearDownAudioTitle(); - tearDownAudioMetadata(); - - hideAudio(); - } - - private void tearDownAudioTitle() { - getSupportActionBar().setTitle(null); - } - - private void tearDownAudioMetadata() { - getAudioTitleView().setText(null); - getAudioSubtitleView().setText(null); - getAudioAlbumArtView().setImageBitmap(null); - } - - private void hideAudio() { - hideAudioMetadata(); - hideAudioControls(); - } - - private void hideAudioMetadata() { - ViewDirector.of(this, R.id.animator).show(android.R.id.progress); - } - - private void hideAudioControls() { - if (areAudioControlsAvailable() && audioControls.isShowing()) { - audioControls.hideAnimated(); - } - } - - @Override - public void start() { - audioService.playAudio(); - } - - @Override - public boolean canPause() { - return true; - } - - @Override - public void pause() { - audioService.pauseAudio(); - } - - @Override - public boolean canSeekBackward() { - return true; - } - - @Override - public boolean canSeekForward() { - return true; - } - - @Override - public void seekTo(int time) { - audioService.getAudioPlayer().seekTo(time); - } - - @Override - public int getDuration() { - return audioService.getAudioPlayer().getDuration(); - } - - @Override - public int getCurrentPosition() { - return audioService.getAudioPlayer().getCurrentPosition(); - } - - @Override - public boolean isPlaying() { - return audioService.getAudioPlayer().isPlaying(); - } - - @Override - public int getBufferPercentage() { - return 0; - } - - @Override - public int getAudioSessionId() { - return audioService.getAudioPlayer().getAudioSessionId(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case android.R.id.home: - finish(); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - @Override - protected void onResume() { - super.onResume(); - - showAudioControlsForced(); - - BusProvider.getBus().register(this); - - setUpAudioMetadata(); - } - - private void showAudioControlsForced() { - if (areAudioControlsAvailable() && !audioControls.isShowing()) { - audioControls.show(); - } - } - - private void setUpAudioMetadata() { - if (!isAudioServiceAvailable()) { - return; - } - - if (!this.audioFile.equals(audioService.getAudioFile())) { - this.audioFile = audioService.getAudioFile(); - - tearDownAudioTitle(); - tearDownAudioMetadata(); - - setUpAudioTitle(); - setUpAudioMetadata(audioService.getAudioMetadataFormatter(), audioService.getAudioAlbumArt()); - } - } - - private boolean isAudioServiceAvailable() { - return audioService != null; - } - - @Override - protected void onPause() { - super.onPause(); - - hideAudioControlsForced(); - - BusProvider.getBus().unregister(this); - - if (isFinishing()) { - tearDownAudioPlayback(); - } - } - - private void hideAudioControlsForced() { - if (areAudioControlsAvailable() && audioControls.isShowing()) { - audioControls.hide(); - } - } - - private void tearDownAudioPlayback() { - audioService.pauseAudio(); - } - - @Override - protected void onStop() { - super.onStop(); - - tearDownAudioServiceBind(); - } - - private void tearDownAudioServiceBind() { - unbindService(this); - } - - @Override - protected void onSaveInstanceState(Bundle state) { - super.onSaveInstanceState(state); - - if (isAudioMetadataLoaded()) { - tearDownAudioMetadataState(state); - } - } - - private boolean isAudioMetadataLoaded() { - String audioTitle = getAudioTitleView().getText().toString(); - String audioSubtitle = getAudioSubtitleView().getText().toString(); - BitmapDrawable audioAlbumArt = (BitmapDrawable) getAudioAlbumArtView().getDrawable(); - - return !audioTitle.isEmpty() && !audioSubtitle.isEmpty() && (audioAlbumArt != null); - } - - private void tearDownAudioMetadataState(Bundle state) { - String audioTitle = getAudioTitleView().getText().toString(); - String audioSubtitle = getAudioSubtitleView().getText().toString(); - BitmapDrawable audioAlbumArt = (BitmapDrawable) getAudioAlbumArtView().getDrawable(); - - state.putString(State.AUDIO_TITLE, audioTitle); - state.putString(State.AUDIO_SUBTITLE, audioSubtitle); - state.putParcelable(State.AUDIO_ALBUM_ART, audioAlbumArt.getBitmap()); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (isFinishing()) { - tearDownAudioService(); - } - } - - private void tearDownAudioService() { - Intent intent = new Intent(this, AudioService.class); - stopService(intent); - } - - private static final class AudioControlsNextListener implements View.OnClickListener - { - @Override - public void onClick(View view) { - BusProvider.getBus().post(new AudioControlNextEvent()); - } - } - - private static final class AudioControlsPreviousListener implements View.OnClickListener - { - @Override - public void onClick(View view) { - BusProvider.getBus().post(new AudioControlPreviousEvent()); - } - } +public class ServerFileAudioActivity extends AppCompatActivity implements + ServiceConnection, + MediaController.MediaPlayerControl, + SessionManagerListener { + private static final Set SUPPORTED_FORMATS; + + static { + SUPPORTED_FORMATS = new HashSet<>(Arrays.asList( + "audio/flac", + "audio/mp4", + "audio/mpeg", + "audio/ogg" + )); + } + + @Inject + ServerClient serverClient; + private CastContext mCastContext; + private CastSession mCastSession; + private AudioMetadataFormatter metadataFormatter; + private PlaybackLocation mLocation = PlaybackLocation.LOCAL; + private AudioService audioService; + private AudioControls audioControls; + private ServerFile audioFile; + + public static boolean supports(String mime_type) { + return SUPPORTED_FORMATS.contains(mime_type); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_file_audio); + + setUpInjections(); + + setUpHomeNavigation(); + + setUpCast(); + + setUpAudio(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpCast() { + mCastContext = CastContext.getSharedInstance(this); + mCastSession = mCastContext.getSessionManager().getCurrentCastSession(); + if (mCastSession != null && mCastSession.isConnected()) { + mLocation = PlaybackLocation.REMOTE; + } + } + + private void setUpAudio() { + setUpAudioFile(); + setUpAudioTitle(); + } + + private void setUpAudioFile() { + this.audioFile = getFile(); + } + + private ServerFile getFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private void setUpAudioTitle() { + getSupportActionBar().setTitle(audioFile.getName()); + } + + private TextView getAudioTitleView() { + return (TextView) findViewById(R.id.text_title); + } + + private TextView getAudioSubtitleView() { + return (TextView) findViewById(R.id.text_subtitle); + } + + private ImageView getAudioAlbumArtView() { + return (ImageView) findViewById(R.id.image_album_art); + } + + private void showAudioMetadata() { + ViewDirector.of(this, R.id.animator).show(R.id.layout_content); + } + + @Subscribe + public void onAudioMetadataRetrieved(AudioMetadataRetrievedEvent event) { + if (audioFile != null && audioFile == event.getServerFile()) { + metadataFormatter = new AudioMetadataFormatter( + event.getAudioTitle(), event.getAudioArtist(), event.getAudioAlbum()); + metadataFormatter.setDuration(event.getDuration()); + if (mLocation == PlaybackLocation.LOCAL) { + setUpAudioMetadata(metadataFormatter, event.getAudioAlbumArt()); + } else if (mLocation == PlaybackLocation.REMOTE) { + loadRemoteMedia(0, true); + finish(); + } + } + } + + private void setUpAudioMetadata(AudioMetadataFormatter audioMetadataFormatter, Bitmap audioAlbumArt) { + getAudioTitleView().setText(audioMetadataFormatter.getAudioTitle(audioFile)); + getAudioSubtitleView().setText(audioMetadataFormatter.getAudioSubtitle(getShare())); + if (audioAlbumArt == null) { + audioAlbumArt = BitmapFactory.decodeResource(getResources(), R.drawable.default_audiotrack); + getAudioAlbumArtView().setScaleType(ImageView.ScaleType.CENTER_INSIDE); + } else { + getAudioAlbumArtView().setScaleType(ImageView.ScaleType.CENTER_CROP); + } + getAudioAlbumArtView().setImageBitmap(audioAlbumArt); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private Uri getAudioUri() { + return serverClient.getFileUri(getShare(), getFile()); + } + + @Override + protected void onStart() { + super.onStart(); + + if (mLocation == PlaybackLocation.LOCAL) { + setUpAudioService(); + setUpAudioServiceBind(); + } else if (mLocation == PlaybackLocation.REMOTE) { + AudioMetadataRetrievingTask.execute(getAudioUri(), audioFile); + } + } + + private void setUpAudioService() { + Intent intent = new Intent(this, AudioService.class); + startService(intent); + } + + private void setUpAudioServiceBind() { + Intent intent = new Intent(this, AudioService.class); + bindService(intent, this, Context.BIND_AUTO_CREATE); + } + + @Override + public void onServiceDisconnected(ComponentName serviceName) { + } + + @Override + public void onServiceConnected(ComponentName serviceName, IBinder serviceBinder) { + setUpAudioServiceBind(serviceBinder); + + setUpAudioControls(); + setUpAudioPlayback(); + } + + private void setUpAudioServiceBind(IBinder serviceBinder) { + AudioService.AudioServiceBinder audioServiceBinder = (AudioService.AudioServiceBinder) serviceBinder; + audioService = audioServiceBinder.getAudioService(); + } + + private void setUpAudioControls() { + if (!areAudioControlsAvailable()) { + audioControls = new AudioControls(this); + + audioControls.setMediaPlayer(this); + audioControls.setPrevNextListeners(new AudioControlsNextListener(), new AudioControlsPreviousListener()); + audioControls.setAnchorView(findViewById(R.id.animator)); + } + } + + private boolean areAudioControlsAvailable() { + return audioControls != null; + } + + private void setUpAudioPlayback() { + if (audioService.isAudioStarted()) { + showAudio(); + setUpAudioMetadata(); + } else { + audioService.startAudio(getShare(), getAudioFiles(), getFile()); + } + } + + private List getAudioFiles() { + List audioFiles = new ArrayList(); + + for (ServerFile file : getFiles()) { + if (SUPPORTED_FORMATS.contains(file.getMime())) { + audioFiles.add(file); + } + } + + return audioFiles; + } + + private List getFiles() { + return getIntent().getParcelableArrayListExtra(Intents.Extras.SERVER_FILES); + } + + @Subscribe + public void onAudioPrepared(AudioPreparedEvent event) { + this.audioFile = audioService.getAudioFile(); + + start(); + + setUpAudioTitle(); + + showAudio(); + } + + private void showAudio() { + showAudioMetadata(); + showAudioControls(); + } + + private void showAudioControls() { + if (areAudioControlsAvailable() && !audioControls.isShowing()) { + audioControls.show(0); + } + } + + @Subscribe + public void onNextAudio(AudioControlNextEvent event) { + tearDownAudioTitle(); + hideAudio(); + tearDownAudioMetadata(); + } + + @Subscribe + public void onPreviousAudio(AudioControlPreviousEvent event) { + tearDownAudioTitle(); + hideAudio(); + tearDownAudioMetadata(); + } + + @Subscribe + public void onAudioCompleted(AudioCompletedEvent event) { + tearDownAudioTitle(); + hideAudio(); + tearDownAudioMetadata(); + } + + private void tearDownAudioTitle() { + getSupportActionBar().setTitle(null); + } + + private void tearDownAudioMetadata() { + metadataFormatter = null; + getAudioTitleView().setText(null); + getAudioSubtitleView().setText(null); + getAudioAlbumArtView().setScaleType(ImageView.ScaleType.CENTER_INSIDE); + getAudioAlbumArtView().setImageBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.default_audiotrack)); + } + + private void hideAudio() { + hideAudioMetadata(); + hideAudioControls(); + } + + private void hideAudioMetadata() { + ViewDirector.of(this, R.id.animator).show(android.R.id.progress); + } + + private void hideAudioControls() { + if (areAudioControlsAvailable() && audioControls.isShowing()) { + audioControls.hideControls(); + } + } + + @Override + public void start() { + audioService.playAudio(); + } + + @Override + public boolean canPause() { + return true; + } + + @Override + public void pause() { + audioService.pauseAudio(); + } + + @Override + public boolean canSeekBackward() { + return true; + } + + @Override + public boolean canSeekForward() { + return true; + } + + @Override + public void seekTo(int time) { + audioService.getAudioPlayer().seekTo(time); + } + + @Override + public int getDuration() { + return audioService.getAudioPlayer().getDuration(); + } + + @Override + public int getCurrentPosition() { + return audioService.getAudioPlayer().getCurrentPosition(); + } + + @Override + public boolean isPlaying() { + return audioService.getAudioPlayer().isPlaying(); + } + + @Override + public int getBufferPercentage() { + return 0; + } + + @Override + public int getAudioSessionId() { + return audioService.getAudioPlayer().getAudioSessionId(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.action_bar_cast_button, menu); + CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, + R.id.media_route_menu_item); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + protected void onResume() { + super.onResume(); + + mCastContext.getSessionManager().addSessionManagerListener(this, CastSession.class); + + showAudioControlsForced(); + + BusProvider.getBus().register(this); + + if (hasAudioFileChanged()) { + setUpAudioMetadata(); + } + } + + private void showAudioControlsForced() { + if (areAudioControlsAvailable() && !audioControls.isShowing()) { + audioControls.show(0); + } + } + + private boolean hasAudioFileChanged() { + return isAudioServiceAvailable() && !this.audioFile.equals(audioService.getAudioFile()); + } + + private void setUpAudioMetadata() { + if (!isAudioServiceAvailable() || audioService.getAudioMetadataFormatter() == null) { + return; + } + + metadataFormatter = audioService.getAudioMetadataFormatter(); + this.audioFile = audioService.getAudioFile(); + + tearDownAudioTitle(); + tearDownAudioMetadata(); + + setUpAudioTitle(); + setUpAudioMetadata(audioService.getAudioMetadataFormatter(), audioService.getAudioAlbumArt()); + } + + private boolean isAudioServiceAvailable() { + return audioService != null; + } + + @Override + protected void onPause() { + super.onPause(); + + mCastContext.getSessionManager().removeSessionManagerListener(this, CastSession.class); + + hideAudioControlsForced(); + + BusProvider.getBus().unregister(this); + + if (isAudioServiceAvailable() && isFinishing()) { + tearDownAudioPlayback(); + } + } + + private void hideAudioControlsForced() { + if (areAudioControlsAvailable() && audioControls.isShowing()) { + audioControls.hideControls(); + } + } + + private void tearDownAudioPlayback() { + audioService.pauseAudio(); + } + + @Override + protected void onStop() { + super.onStop(); + + if (isAudioServiceAvailable()) { + tearDownAudioServiceBind(); + } + } + + private void tearDownAudioServiceBind() { + unbindService(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (isAudioServiceAvailable() && isFinishing()) { + tearDownAudioService(); + } + } + + private void tearDownAudioService() { + Intent intent = new Intent(this, AudioService.class); + stopService(intent); + } + + @Override + public void onSessionEnded(CastSession session, int error) { + onApplicationDisconnected(); + } + + @Override + public void onSessionResumed(CastSession session, boolean wasSuspended) { + onApplicationConnected(session); + } + + @Override + public void onSessionResumeFailed(CastSession session, int error) { + onApplicationDisconnected(); + } + + @Override + public void onSessionStarted(CastSession session, String sessionId) { + onApplicationConnected(session); + } + + @Override + public void onSessionStartFailed(CastSession session, int error) { + onApplicationDisconnected(); + } + + @Override + public void onSessionStarting(CastSession session) { + } + + @Override + public void onSessionEnding(CastSession session) { + } + + @Override + public void onSessionResuming(CastSession session, String sessionId) { + } + + @Override + public void onSessionSuspended(CastSession session, int reason) { + } + + private void onApplicationConnected(CastSession castSession) { + mCastSession = castSession; + boolean isPlaying = false; + int position = 0; + if (audioService != null) { + isPlaying = audioService.isAudioStarted(); + if (isPlaying) { + audioService.pauseAudio(); + position = audioService.getAudioPlayer().getCurrentPosition(); + } + } + loadRemoteMedia(position, isPlaying); + finish(); + } + + private void onApplicationDisconnected() { + mCastSession = null; + mLocation = PlaybackLocation.LOCAL; + invalidateOptionsMenu(); + if (!isAudioServiceAvailable()) { + setUpAudioService(); + setUpAudioServiceBind(); + } + } + + private void loadRemoteMedia(int position, boolean autoPlay) { + if (mCastSession == null) { + return; + } + final RemoteMediaClient remoteMediaClient = mCastSession.getRemoteMediaClient(); + if (remoteMediaClient == null) { + return; + } + remoteMediaClient.addListener(new RemoteMediaClient.Listener() { + @Override + public void onStatusUpdated() { + Intent intent = new Intent(ServerFileAudioActivity.this, ExpandedControlsActivity.class); + startActivity(intent); + remoteMediaClient.removeListener(this); + } + + @Override + public void onMetadataUpdated() { + } + + @Override + public void onQueueStatusUpdated() { + } + + @Override + public void onPreloadStatusUpdated() { + } + + @Override + public void onSendingRemoteMediaRequest() { + } + + @Override + public void onAdBreakStatusUpdated() { + } + }); + MediaLoadOptions mediaLoadOptions = new MediaLoadOptions.Builder() + .setAutoplay(autoPlay) + .setPlayPosition(position) + .build(); + remoteMediaClient.load(buildMediaInfo(), mediaLoadOptions); + } + + private MediaInfo buildMediaInfo() { + MediaMetadata audioMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK); + + if (metadataFormatter != null) { + audioMetadata.putString(MediaMetadata.KEY_TITLE, metadataFormatter.getAudioTitle(getFile())); + audioMetadata.putString(MediaMetadata.KEY_ARTIST, metadataFormatter.getAudioArtist()); + audioMetadata.putString(MediaMetadata.KEY_ALBUM_TITLE, metadataFormatter.getAudioAlbum()); + } else { + audioMetadata.putString(MediaMetadata.KEY_TITLE, getFile().getNameOnly()); + } + + String audioSource = serverClient.getFileUri(getShare(), getFile()).toString(); + MediaInfo.Builder builder = new MediaInfo.Builder(audioSource) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(getFile().getMime()) + .setMetadata(audioMetadata); + if (metadataFormatter != null) { + builder.setStreamDuration(metadataFormatter.getDuration()); + } + return builder.build(); + } + + private enum PlaybackLocation { + LOCAL, + REMOTE + } + + private static final class AudioControlsNextListener implements View.OnClickListener { + @Override + public void onClick(View view) { + BusProvider.getBus().post(new AudioControlNextEvent()); + } + } + + private static final class AudioControlsPreviousListener implements View.OnClickListener { + @Override + public void onClick(View view) { + BusProvider.getBus().post(new AudioControlPreviousEvent()); + } + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ServerFileImageActivity.java b/src/main/java/org/amahi/anywhere/activity/ServerFileImageActivity.java index a3247a67a..60faf7970 100644 --- a/src/main/java/org/amahi/anywhere/activity/ServerFileImageActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/ServerFileImageActivity.java @@ -28,6 +28,13 @@ import android.view.Menu; import android.view.MenuItem; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.cast.framework.media.RemoteMediaClient; import com.squareup.otto.Subscribe; import org.amahi.anywhere.AmahiApplication; @@ -55,195 +62,292 @@ * Image activity. Shows images as a slide show. * Backed up by {@link org.amahi.anywhere.view.TouchImageView}. */ -public class ServerFileImageActivity extends AppCompatActivity implements ViewPager.OnPageChangeListener -{ - private static final Set SUPPORTED_FORMATS; - - static { - SUPPORTED_FORMATS = new HashSet<>(Arrays.asList( - "image/bmp", - "image/jpeg", - "image/gif", - "image/png", - "image/webp" - )); - } - - @Inject - ServerClient serverClient; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_server_file_image); - - setUpInjections(); - - setUpHomeNavigation(); - - setUpImage(); - - setUpFullScreen(); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpFullScreen() { - final FullScreenHelper fullScreen = new FullScreenHelper(getSupportActionBar(), getImagePager()); - fullScreen.enableOnClickToggle(false); - getImagePager().setOnViewPagerClickListener(new ClickableViewPager.OnClickListener() { - @Override - public void onViewPagerClick(ViewPager viewPager) { - fullScreen.toggle(); - } - }); - fullScreen.init(); - } - - private void setUpHomeNavigation() { - getSupportActionBar().setHomeButtonEnabled(true); - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } - - private void setUpImage() { - setUpImageTitle(); - setUpImageAdapter(); - setUpImagePosition(); - setUpImageListener(); - } - - private void setUpImageTitle() { - setUpImageTitle(getFile()); - } - - private void setUpImageTitle(ServerFile file) { - getSupportActionBar().setTitle(file.getName()); - } - - private ServerFile getFile() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); - } - - private void setUpImageAdapter() { - getImagePager().setAdapter(new ServerFilesImagePagerAdapter(getSupportFragmentManager(), getShare(), getImageFiles())); - } - - private ClickableViewPager getImagePager() { - return (ClickableViewPager) findViewById(R.id.pager_images); - } - - private ServerShare getShare() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); - } - - private List getImageFiles() { - List imageFiles = new ArrayList(); - - for (ServerFile file : getFiles()) { - if (SUPPORTED_FORMATS.contains(file.getMime())) { - imageFiles.add(file); - } - } - - return imageFiles; - } - - private List getFiles() { - return getIntent().getParcelableArrayListExtra(Intents.Extras.SERVER_FILES); - } - - private void setUpImagePosition() { - getImagePager().setCurrentItem(getImageFiles().indexOf(getFile())); - } - - private void setUpImageListener() { - getImagePager().addOnPageChangeListener(this); - } - - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - } - - @Override - public void onPageScrollStateChanged(int state) { - } - - @Override - public void onPageSelected(int position) { - setUpImageTitle(getImageFiles().get(position)); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.action_bar_server_file_image, menu); - - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case android.R.id.home: - finish(); - return true; - - case R.id.menu_share: - startFileSharingActivity(); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - private void startFileSharingActivity() { - startFileDownloading(getShare(), getCurrentFile()); - } - - private ServerFile getCurrentFile() { - return getImageFiles().get(getImagePager().getCurrentItem()); - } - - private void startFileDownloading(ServerShare share, ServerFile file) { - showFileDownloadingFragment(share, file); - } - - private void showFileDownloadingFragment(ServerShare share, ServerFile file) { - DialogFragment fragment = ServerFileDownloadingFragment.newInstance(share, file); - fragment.show(getFragmentManager(), ServerFileDownloadingFragment.TAG); - } - - @Subscribe - public void onFileDownloaded(FileDownloadedEvent event) { - finishFileDownloading(event.getFileUri()); - } - - private void finishFileDownloading(Uri fileUri) { - startFileSharingActivity(getCurrentFile(), fileUri); - } - - private void startFileSharingActivity(ServerFile file, Uri fileUri) { - Intent intent = Intents.Builder.with(this).buildServerFileSharingIntent(file, fileUri); - startActivity(intent); - } - - @Override - protected void onResume() { - super.onResume(); - - BusProvider.getBus().register(this); - } - - @Override - protected void onPause() { - super.onPause(); - - BusProvider.getBus().unregister(this); - } - - public static boolean supports(String mime_type) { - return SUPPORTED_FORMATS.contains(mime_type); - } +public class ServerFileImageActivity extends AppCompatActivity implements + ViewPager.OnPageChangeListener, + SessionManagerListener { + private static final Set SUPPORTED_FORMATS; + + static { + SUPPORTED_FORMATS = new HashSet<>(Arrays.asList( + "image/bmp", + "image/jpeg", + "image/gif", + "image/png", + "image/webp" + )); + } + + @Inject + ServerClient serverClient; + private CastSession mCastSession; + private CastContext mCastContext; + private int imagePosition; + + public static boolean supports(String mime_type) { + return SUPPORTED_FORMATS.contains(mime_type); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_file_image); + + setUpInjections(); + + setUpHomeNavigation(); + + setUpImage(); + + setUpCast(); + + setUpFullScreen(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpFullScreen() { + final FullScreenHelper fullScreen = new FullScreenHelper(getSupportActionBar(), getImagePager()); + fullScreen.enableOnClickToggle(false); + getImagePager().setOnViewPagerClickListener(new ClickableViewPager.OnClickListener() { + @Override + public void onViewPagerClick(ViewPager viewPager) { + fullScreen.toggle(); + } + }); + fullScreen.init(); + } + + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpImage() { + setUpImageTitle(); + setUpImageAdapter(); + setUpImagePosition(); + setUpImageListener(); + } + + private boolean isCastConnected() { + return mCastSession != null && mCastSession.isConnected(); + } + + private void setUpCast() { + mCastContext = CastContext.getSharedInstance(this); + mCastSession = mCastContext.getSessionManager().getCurrentCastSession(); + if (isCastConnected()) { + loadRemoteMedia(); + } + } + + private void setUpImageTitle() { + setUpImageTitle(getFile()); + } + + private void setUpImageTitle(ServerFile file) { + getSupportActionBar().setTitle(file.getName()); + } + + private ServerFile getFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private void setUpImageAdapter() { + getImagePager().setAdapter(new ServerFilesImagePagerAdapter(getSupportFragmentManager(), getShare(), getImageFiles())); + } + + private ClickableViewPager getImagePager() { + return (ClickableViewPager) findViewById(R.id.pager_images); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private List getImageFiles() { + List imageFiles = new ArrayList<>(); + + for (ServerFile file : getFiles()) { + if (SUPPORTED_FORMATS.contains(file.getMime())) { + imageFiles.add(file); + } + } + + return imageFiles; + } + + private List getFiles() { + return getIntent().getParcelableArrayListExtra(Intents.Extras.SERVER_FILES); + } + + private void setUpImagePosition() { + imagePosition = getImageFiles().indexOf(getFile()); + getImagePager().setCurrentItem(imagePosition); + } + + private void setUpImageListener() { + getImagePager().addOnPageChangeListener(this); + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + } + + @Override + public void onPageScrollStateChanged(int state) { + } + + @Override + public void onPageSelected(int position) { + this.imagePosition = position; + setUpImageTitle(getImageFiles().get(position)); + if (isCastConnected()) { + loadRemoteMedia(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.action_bar_server_file_image, menu); + CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, + R.id.media_route_menu_item); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + return true; + + case R.id.menu_share: + startFileSharingActivity(); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + private void startFileSharingActivity() { + startFileDownloading(getShare(), getCurrentFile()); + } + + private ServerFile getCurrentFile() { + return getImageFiles().get(getImagePager().getCurrentItem()); + } + + private void startFileDownloading(ServerShare share, ServerFile file) { + showFileDownloadingFragment(share, file); + } + + private void showFileDownloadingFragment(ServerShare share, ServerFile file) { + DialogFragment fragment = ServerFileDownloadingFragment.newInstance(share, file); + fragment.show(getFragmentManager(), ServerFileDownloadingFragment.TAG); + } + + @Subscribe + public void onFileDownloaded(FileDownloadedEvent event) { + finishFileDownloading(event.getFileUri()); + } + + private void finishFileDownloading(Uri fileUri) { + startFileSharingActivity(getCurrentFile(), fileUri); + } + + private void startFileSharingActivity(ServerFile file, Uri fileUri) { + Intent intent = Intents.Builder.with(this).buildServerFileSharingIntent(file, fileUri); + startActivity(intent); + } + + @Override + protected void onResume() { + super.onResume(); + + mCastContext.getSessionManager().addSessionManagerListener(this, CastSession.class); + BusProvider.getBus().register(this); + } + + @Override + protected void onPause() { + super.onPause(); + + mCastContext.getSessionManager().removeSessionManagerListener(this, CastSession.class); + BusProvider.getBus().unregister(this); + } + + @Override + public void onSessionEnded(CastSession session, int error) { + onApplicationDisconnected(); + } + + @Override + public void onSessionResumed(CastSession session, boolean wasSuspended) { + onApplicationConnected(session); + } + + @Override + public void onSessionResumeFailed(CastSession session, int error) { + onApplicationDisconnected(); + } + + @Override + public void onSessionStarted(CastSession session, String sessionId) { + onApplicationConnected(session); + } + + @Override + public void onSessionStartFailed(CastSession session, int error) { + onApplicationDisconnected(); + } + + @Override + public void onSessionStarting(CastSession session) { + } + + @Override + public void onSessionEnding(CastSession session) { + } + + @Override + public void onSessionResuming(CastSession session, String sessionId) { + } + + @Override + public void onSessionSuspended(CastSession session, int reason) { + } + + private void onApplicationConnected(CastSession castSession) { + mCastSession = castSession; + invalidateOptionsMenu(); + loadRemoteMedia(); + } + + private void onApplicationDisconnected() { + mCastSession = null; + invalidateOptionsMenu(); + } + + private void loadRemoteMedia() { + final RemoteMediaClient remoteMediaClient = mCastSession.getRemoteMediaClient(); + if (remoteMediaClient != null) { + remoteMediaClient.load(buildMediaInfo()); + } + } + + private MediaInfo buildMediaInfo() { + ServerFile file = getImageFiles().get(imagePosition); + MediaMetadata imageMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO); + imageMetadata.putString(MediaMetadata.KEY_TITLE, file.getNameOnly()); + String imageUrl = serverClient.getFileUri(getShare(), file).toString(); + return new MediaInfo.Builder(imageUrl) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(file.getMime()) + .setMetadata(imageMetadata) + .build(); + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ServerFileVideoActivity.java b/src/main/java/org/amahi/anywhere/activity/ServerFileVideoActivity.java index 1e24909cc..f87a21cc0 100644 --- a/src/main/java/org/amahi/anywhere/activity/ServerFileVideoActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/ServerFileVideoActivity.java @@ -19,12 +19,14 @@ package org.amahi.anywhere.activity; +import android.annotation.TargetApi; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.content.res.Configuration; import android.graphics.PixelFormat; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; @@ -35,8 +37,11 @@ import android.view.SurfaceView; import android.view.View; import android.view.ViewGroup; +import android.view.ViewStub; import android.widget.FrameLayout; import android.widget.MediaController; +import android.widget.ProgressBar; +import android.widget.Toast; import org.amahi.anywhere.AmahiApplication; import org.amahi.anywhere.R; @@ -45,7 +50,7 @@ import org.amahi.anywhere.service.VideoService; import org.amahi.anywhere.util.FullScreenHelper; import org.amahi.anywhere.util.Intents; -import org.amahi.anywhere.util.ViewDirector; +import org.amahi.anywhere.util.VideoSwipeGestures; import org.amahi.anywhere.view.MediaControls; import org.videolan.libvlc.IVLCVout; import org.videolan.libvlc.Media; @@ -57,525 +62,561 @@ * Backed up by {@link android.view.SurfaceView} and {@link org.videolan.libvlc.LibVLC}. */ public class ServerFileVideoActivity extends AppCompatActivity implements - ServiceConnection, - MediaController.MediaPlayerControl, - IVLCVout.OnNewVideoLayoutListener, - MediaPlayer.EventListener, - View.OnLayoutChangeListener { - - private VideoService videoService; - private MediaControls videoControls; - private FullScreenHelper fullScreen; - private Handler layoutChangeHandler; - - private int mVideoHeight = 0; - private int mVideoWidth = 0; - private int mVideoVisibleHeight = 0; - private int mVideoVisibleWidth = 0; - private int mVideoSarNum = 0; - private int mVideoSarDen = 0; - - private enum SurfaceSizes { - SURFACE_BEST_FIT, - SURFACE_FIT_SCREEN, - SURFACE_FILL, - SURFACE_16_9, - SURFACE_4_3, - SURFACE_ORIGINAL; - } - - private static SurfaceSizes CURRENT_SIZE = SurfaceSizes.SURFACE_BEST_FIT; - - //TODO Add feature for changing the screen size - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_server_file_video); - - setUpInjections(); - - setUpHomeNavigation(); - - setUpVideo(); - - setUpFullScreen(); - - setUpVideoService(); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpHomeNavigation() { - getSupportActionBar().setHomeButtonEnabled(true); - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } - - private void setUpVideo() { - setUpVideoTitle(); - } - - private void setUpVideoTitle() { - getSupportActionBar().setTitle(getVideoFile().getName()); - } - - private ServerFile getVideoFile() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); - } - - private void setUpFullScreen() { - fullScreen = new FullScreenHelper(getSupportActionBar(), getSurfaceFrame(), getControlsContainer()); - fullScreen.enableOnClickToggle(false); - getSurfaceFrame().setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - fullScreen.toggle(); - videoControls.toggle(); - } - }); - fullScreen.init(); - } - - private MediaPlayer getMediaPlayer() { - assert videoService != null; - return videoService.getMediaPlayer(); - } - - @Override - protected void onStart() { - super.onStart(); - setUpVideoServiceBind(); - } - - private void setUpVideoService() { - Intent intent = new Intent(this, VideoService.class); - startService(intent); - } - - private void setUpVideoServiceBind() { - Intent intent = new Intent(this, VideoService.class); - bindService(intent, this, Context.BIND_AUTO_CREATE); - } - - @Override - public void onServiceDisconnected(ComponentName serviceName) { - } - - @Override - public void onServiceConnected(ComponentName serviceName, IBinder serviceBinder) { - setUpVideoServiceBind(serviceBinder); - - setUpVideoView(); - setUpVideoControls(); - setUpHandlers(); - setUpVideoPlayback(); - } - - private void setUpVideoServiceBind(IBinder serviceBinder) { - VideoService.VideoServiceBinder videoServiceBinder = (VideoService.VideoServiceBinder) serviceBinder; - videoService = videoServiceBinder.getVideoService(); - } - - private void setUpVideoView() { - SurfaceHolder surfaceHolder = getSurface().getHolder(); - - surfaceHolder.setFormat(PixelFormat.RGBX_8888); - surfaceHolder.setKeepScreenOn(true); - final IVLCVout vlcVout = getMediaPlayer().getVLCVout(); - vlcVout.setVideoView(getSurface()); - vlcVout.attachViews(this); - getMediaPlayer().setEventListener(this); - } - - private SurfaceView getSurface() { - return (SurfaceView) findViewById(R.id.surface); - } - - private FrameLayout getSurfaceFrame() { - return (FrameLayout) findViewById(R.id.layout_content); - } - - private void setUpVideoControls() { - if (!areVideoControlsAvailable()) { - videoControls = new MediaControls(this); - - videoControls.setMediaPlayer(this); - videoControls.setAnchorView(getControlsContainer()); - } - } - - private boolean areVideoControlsAvailable() { - return videoControls != null; - } - - private void setUpHandlers() { - if (!layoutChangeHandlerAvailable()) { - layoutChangeHandler = new Handler(); - } - } - - private boolean layoutChangeHandlerAvailable() { - return layoutChangeHandler != null; - } - - private View getControlsContainer() { - return findViewById(R.id.container_controls); - } - - private void setUpVideoPlayback() { - if (videoService.isVideoStarted()) { - showVideo(); - showThenAutoHideControls(); - } else { - videoService.startVideo(getVideoShare(), getVideoFile()); - addLayoutChangeListener(); - } - } - - private void showThenAutoHideControls() { - if (!isFinishing()) { - fullScreen.show(); - fullScreen.delayedHide(); - videoControls.showAnimated(); - videoControls.hideControlsDelayed(); - } - } - - private void showVideo() { - ViewDirector.of(this, R.id.animator).show(R.id.layout_content); - } - - private ServerShare getVideoShare() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); - } - - private void addLayoutChangeListener() { - getSurfaceFrame().addOnLayoutChangeListener(this); - } - - @Override - public void onLayoutChange(View v, int left, int top, int right, - int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { - layoutChangeHandler.removeCallbacks(mRunnable); - layoutChangeHandler.post(mRunnable); - } - } - - private final Runnable mRunnable = new Runnable() { - @Override - public void run() { - updateVideoSurfaces(); - } - }; - - @Override - public void onNewVideoLayout(IVLCVout vout, int width, int height, int visibleWidth, int visibleHeight, int sarNum, int sarDen) { - mVideoWidth = width; - mVideoHeight = height; - mVideoVisibleWidth = visibleWidth; - mVideoVisibleHeight = visibleHeight; - mVideoSarNum = sarNum; - mVideoSarDen = sarDen; - updateVideoSurfaces(); - } - - private void updateVideoSurfaces() { - int screenWidth = getWindow().getDecorView().getWidth(); - int screenHeight = getWindow().getDecorView().getHeight(); - - // sanity check - if (screenWidth * screenHeight == 0) { - Log.e("Error", "Invalid surface size"); - return; - } - - getMediaPlayer().getVLCVout().setWindowSize(screenWidth, screenHeight); - ViewGroup.LayoutParams lp = getSurface().getLayoutParams(); - if (mVideoWidth * mVideoHeight == 0) { + ServiceConnection, + MediaController.MediaPlayerControl, + IVLCVout.OnNewVideoLayoutListener, + MediaPlayer.EventListener, + View.OnLayoutChangeListener, + VideoSwipeGestures.SeekControl { + + private static final boolean ENABLE_SUBTITLES = false; + private static SurfaceSizes CURRENT_SIZE = SurfaceSizes.SURFACE_BEST_FIT; + private VideoService videoService; + private MediaControls videoControls; + private FullScreenHelper fullScreen; + private Handler layoutChangeHandler; + private int mVideoHeight = 0; + private int mVideoWidth = 0; + private int mVideoVisibleHeight = 0; + private int mVideoVisibleWidth = 0; + private int mVideoSarNum = 0; + private int mVideoSarDen = 0; + private SurfaceView mSubtitlesSurface = null; + private final Runnable mRunnable = new Runnable() { + @Override + public void run() { + updateVideoSurfaces(); + } + }; + private float bufferPercent = 0.0f; + + //TODO Add feature for changing the screen size + + public static boolean supports(String mime_type) { + String type = mime_type.split("/")[0]; + + return "video".equals(type); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_file_video); + + setUpInjections(); + + setUpHomeNavigation(); + + setUpViews(); + + setUpVideo(); + + setUpFullScreen(); + + setUpGestureListener(); + + setUpVideoService(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpViews() { + final ViewStub stub = (ViewStub) findViewById(R.id.subtitles_stub); + mSubtitlesSurface = (SurfaceView) stub.inflate(); + mSubtitlesSurface.setZOrderMediaOverlay(true); + mSubtitlesSurface.getHolder().setFormat(PixelFormat.TRANSLUCENT); + } + + private void setUpVideo() { + setUpVideoTitle(); + } + + private void setUpVideoTitle() { + getSupportActionBar().setTitle(getVideoFile().getName()); + } + + private ServerFile getVideoFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private void setUpFullScreen() { + fullScreen = new FullScreenHelper(getSupportActionBar(), getVideoMainFrame()); + fullScreen.enableOnClickToggle(false); + getVideoMainFrame().setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + fullScreen.toggle(); + videoControls.toggle(); + } + }); + fullScreen.init(); + } + + private FrameLayout getSwipeContainer() { + return (FrameLayout) findViewById(R.id.swipe_controls_frame); + } + + private void setUpGestureListener() { + getVideoMainFrame().setOnTouchListener(new VideoSwipeGestures(this, this, getSwipeContainer())); + } + + private MediaPlayer getMediaPlayer() { + assert videoService != null; + return videoService.getMediaPlayer(); + } + + @Override + protected void onStart() { + super.onStart(); + setUpVideoServiceBind(); + } + + private void setUpVideoService() { + Intent intent = new Intent(this, VideoService.class); + startService(intent); + } + + private void setUpVideoServiceBind() { + Intent intent = new Intent(this, VideoService.class); + bindService(intent, this, Context.BIND_AUTO_CREATE); + } + + @Override + public void onServiceDisconnected(ComponentName serviceName) { + } + + @Override + public void onServiceConnected(ComponentName serviceName, IBinder serviceBinder) { + setUpVideoServiceBind(serviceBinder); + + setUpVideoView(); + setUpVideoControls(); + setUpHandlers(); + setUpVideoPlayback(); + } + + private void setUpVideoServiceBind(IBinder serviceBinder) { + VideoService.VideoServiceBinder videoServiceBinder = (VideoService.VideoServiceBinder) serviceBinder; + videoService = videoServiceBinder.getVideoService(); + } + + private void setUpVideoView() { + SurfaceHolder surfaceHolder = getSurface().getHolder(); + + surfaceHolder.setFormat(PixelFormat.RGBX_8888); + surfaceHolder.setKeepScreenOn(true); + final IVLCVout vlcVout = getMediaPlayer().getVLCVout(); + vlcVout.setVideoView(getSurface()); + if (mSubtitlesSurface != null) + vlcVout.setSubtitlesView(mSubtitlesSurface); + vlcVout.attachViews(this); + getMediaPlayer().setEventListener(this); + } + + private SurfaceView getSurface() { + return (SurfaceView) findViewById(R.id.surface); + } + + private FrameLayout getSurfaceFrame() { + return (FrameLayout) findViewById(R.id.video_surface_frame); + } + + private FrameLayout getVideoMainFrame() { + return (FrameLayout) findViewById(R.id.video_main_frame); + } + + private void setUpVideoControls() { + if (!areVideoControlsAvailable()) { + videoControls = new MediaControls(this); + videoControls.setMediaPlayer(this); + videoControls.setAnchorView(getControlsContainer()); + } + + } + + private boolean areVideoControlsAvailable() { + return videoControls != null; + } + + private void setUpHandlers() { + if (!layoutChangeHandlerAvailable()) { + layoutChangeHandler = new Handler(); + } + } + + private boolean layoutChangeHandlerAvailable() { + return layoutChangeHandler != null; + } + + private View getControlsContainer() { + return findViewById(R.id.container_controls); + } + + private void setUpVideoPlayback() { + if (videoService.isVideoStarted()) { + showThenAutoHideControls(); + getVideoMainFrame().setVisibility(View.VISIBLE); + getProgressBar().setVisibility(View.INVISIBLE); + } else { + videoService.startVideo(getVideoShare(), getVideoFile(), ENABLE_SUBTITLES); + addLayoutChangeListener(); + } + } + + private void showThenAutoHideControls() { + if (!isFinishing()) { + fullScreen.show(); + fullScreen.delayedHide(); + videoControls.show(); + } + } + + private ProgressBar getProgressBar() { + return (ProgressBar) findViewById(android.R.id.progress); + } + + private ServerShare getVideoShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private void addLayoutChangeListener() { + getSurfaceFrame().addOnLayoutChangeListener(this); + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, + int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { + if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { + layoutChangeHandler.removeCallbacks(mRunnable); + layoutChangeHandler.post(mRunnable); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + @Override + public void onNewVideoLayout(IVLCVout vout, int width, int height, int visibleWidth, int visibleHeight, int sarNum, int sarDen) { + mVideoWidth = width; + mVideoHeight = height; + mVideoVisibleWidth = visibleWidth; + mVideoVisibleHeight = visibleHeight; + mVideoSarNum = sarNum; + mVideoSarDen = sarDen; + updateVideoSurfaces(); + } + + private void updateVideoSurfaces() { + int screenWidth = getWindow().getDecorView().getWidth(); + int screenHeight = getWindow().getDecorView().getHeight(); + + // sanity check + if (screenWidth * screenHeight == 0) { + Log.e("Error", "Invalid surface size"); + return; + } + + getMediaPlayer().getVLCVout().setWindowSize(screenWidth, screenHeight); + ViewGroup.LayoutParams lp = getSurface().getLayoutParams(); + if (mVideoWidth * mVideoHeight == 0) { /* Case of OpenGL vouts: handles the placement of the video using MediaPlayer API */ - lp.width = ViewGroup.LayoutParams.MATCH_PARENT; - lp.height = ViewGroup.LayoutParams.MATCH_PARENT; - getSurface().setLayoutParams(lp); - lp = getSurfaceFrame().getLayoutParams(); - lp.width = ViewGroup.LayoutParams.MATCH_PARENT; - lp.height = ViewGroup.LayoutParams.MATCH_PARENT; - getSurfaceFrame().setLayoutParams(lp); - changeMediaPlayerLayout(screenWidth, screenHeight); - return; - } - - if (lp.width == lp.height && lp.width == ViewGroup.LayoutParams.MATCH_PARENT) { + lp.width = ViewGroup.LayoutParams.MATCH_PARENT; + lp.height = ViewGroup.LayoutParams.MATCH_PARENT; + getSurface().setLayoutParams(lp); + lp = getSurfaceFrame().getLayoutParams(); + lp.width = ViewGroup.LayoutParams.MATCH_PARENT; + lp.height = ViewGroup.LayoutParams.MATCH_PARENT; + getSurfaceFrame().setLayoutParams(lp); + changeMediaPlayerLayout(screenWidth, screenHeight); + return; + } + + if (lp.width == lp.height && lp.width == ViewGroup.LayoutParams.MATCH_PARENT) { /* We handle the placement of the video using Android View LayoutParams */ - getMediaPlayer().setAspectRatio(null); - getMediaPlayer().setScale(0); - } - - double dw = screenWidth, dh = screenHeight; - final boolean isPortrait = getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT; - if (screenWidth > screenHeight && isPortrait || screenWidth < screenHeight && !isPortrait) { - dw = screenHeight; - dh = screenWidth; - } - - // compute the aspect ratio - double ar, vw; - if (mVideoSarDen == mVideoSarNum) { + getMediaPlayer().setAspectRatio(null); + getMediaPlayer().setScale(0); + } + + double dw = screenWidth, dh = screenHeight; + final boolean isPortrait = getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT; + if (screenWidth > screenHeight && isPortrait || screenWidth < screenHeight && !isPortrait) { + dw = screenHeight; + dh = screenWidth; + } + + // compute the aspect ratio + double ar, vw; + if (mVideoSarDen == mVideoSarNum) { /* No indication about the density, assuming 1:1 */ - vw = mVideoVisibleWidth; - ar = (double)mVideoVisibleWidth / (double)mVideoVisibleHeight; - } else { + vw = mVideoVisibleWidth; + ar = (double) mVideoVisibleWidth / (double) mVideoVisibleHeight; + } else { /* Use the specified aspect ratio */ - vw = mVideoVisibleWidth * (double)mVideoSarNum / mVideoSarDen; - ar = vw / mVideoVisibleHeight; - } - - // compute the display aspect ratio - double dar = dw / dh; - switch (CURRENT_SIZE) { - case SURFACE_BEST_FIT: - if (dar < ar) - dh = dw / ar; - else - dw = dh * ar; - break; - case SURFACE_FIT_SCREEN: - if (dar >= ar) - dh = dw / ar; /* horizontal */ - else - dw = dh * ar; /* vertical */ - break; - case SURFACE_FILL: - break; - case SURFACE_16_9: - ar = 16.0 / 9.0; - if (dar < ar) - dh = dw / ar; - else - dw = dh * ar; - break; - case SURFACE_4_3: - ar = 4.0 / 3.0; - if (dar < ar) - dh = dw / ar; - else - dw = dh * ar; - break; - case SURFACE_ORIGINAL: - dh = mVideoVisibleHeight; - dw = vw; - break; - } - // set display size - lp.width = (int) Math.ceil(dw * mVideoWidth / mVideoVisibleWidth); - lp.height = (int) Math.ceil(dh * mVideoHeight / mVideoVisibleHeight); - getSurface().setLayoutParams(lp); - // set frame size (crop if necessary) - lp = getSurfaceFrame().getLayoutParams(); - lp.width = (int) Math.floor(dw); - lp.height = (int) Math.floor(dh); - getSurfaceFrame().setLayoutParams(lp); - getSurface().invalidate(); - } - - private void changeMediaPlayerLayout(int displayW, int displayH) { + vw = mVideoVisibleWidth * (double) mVideoSarNum / mVideoSarDen; + ar = vw / mVideoVisibleHeight; + } + + // compute the display aspect ratio + double dar = dw / dh; + switch (CURRENT_SIZE) { + case SURFACE_BEST_FIT: + if (dar < ar) + dh = dw / ar; + else + dw = dh * ar; + break; + case SURFACE_FIT_SCREEN: + if (dar >= ar) + dh = dw / ar; /* horizontal */ + else + dw = dh * ar; /* vertical */ + break; + case SURFACE_FILL: + break; + case SURFACE_16_9: + ar = 16.0 / 9.0; + if (dar < ar) + dh = dw / ar; + else + dw = dh * ar; + break; + case SURFACE_4_3: + ar = 4.0 / 3.0; + if (dar < ar) + dh = dw / ar; + else + dw = dh * ar; + break; + case SURFACE_ORIGINAL: + dh = mVideoVisibleHeight; + dw = vw; + break; + } + // set display size + lp.width = (int) Math.ceil(dw * mVideoWidth / mVideoVisibleWidth); + lp.height = (int) Math.ceil(dh * mVideoHeight / mVideoVisibleHeight); + getSurface().setLayoutParams(lp); + if (mSubtitlesSurface != null) + mSubtitlesSurface.setLayoutParams(lp); + + // set frame size (crop if necessary) + lp = getSurfaceFrame().getLayoutParams(); + lp.width = (int) Math.floor(dw); + lp.height = (int) Math.floor(dh); + getSurfaceFrame().setLayoutParams(lp); + lp = getSwipeContainer().getLayoutParams(); + lp.width = (int) Math.floor(dw); + lp.height = (int) Math.floor(dh); + getSwipeContainer().setLayoutParams(lp); + getSurface().invalidate(); + if (mSubtitlesSurface != null) + mSubtitlesSurface.invalidate(); + } + + private void changeMediaPlayerLayout(int displayW, int displayH) { /* Change the video placement using the MediaPlayer API */ - switch (CURRENT_SIZE) { - case SURFACE_BEST_FIT: - getMediaPlayer().setAspectRatio(null); - getMediaPlayer().setScale(0); - break; - case SURFACE_FIT_SCREEN: - case SURFACE_FILL: { - Media.VideoTrack vtrack = getMediaPlayer().getCurrentVideoTrack(); - if (vtrack == null) - return; - final boolean videoSwapped = vtrack.orientation == Media.VideoTrack.Orientation.LeftBottom - || vtrack.orientation == Media.VideoTrack.Orientation.RightTop; - if (CURRENT_SIZE == SurfaceSizes.SURFACE_FIT_SCREEN) { - int videoW = vtrack.width; - int videoH = vtrack.height; - if (videoSwapped) { - int swap = videoW; - videoW = videoH; - videoH = swap; - } - if (vtrack.sarNum != vtrack.sarDen) - videoW = videoW * vtrack.sarNum / vtrack.sarDen; - float videoAspectRatio = videoW / (float) videoH; - float displayAspectRatio = displayW / (float) displayH; - float scale; - if (displayAspectRatio >= videoAspectRatio) - scale = displayW / (float) videoW; /* horizontal */ - else - scale = displayH / (float) videoH; /* vertical */ - getMediaPlayer().setScale(scale); - getMediaPlayer().setAspectRatio(null); - } else { - getMediaPlayer().setScale(0); - getMediaPlayer().setAspectRatio(!videoSwapped ? ""+displayW+":"+displayH - : ""+displayH+":"+displayW); - } - break; - } - case SURFACE_16_9: - getMediaPlayer().setAspectRatio("16:9"); - getMediaPlayer().setScale(0); - break; - case SURFACE_4_3: - getMediaPlayer().setAspectRatio("4:3"); - getMediaPlayer().setScale(0); - break; - case SURFACE_ORIGINAL: - getMediaPlayer().setAspectRatio(null); - getMediaPlayer().setScale(1); - break; - } - } - - @Override - public void start() { - videoService.playVideo(); - } - - @Override - public boolean canPause() { - return true; - } - - @Override - public void pause() { - videoService.pauseVideo(); - } - - @Override - public boolean canSeekBackward() { - return true; - } - - @Override - public boolean canSeekForward() { - return true; - } - - @Override - public void seekTo(int time) { - getMediaPlayer().setTime(time); - } - - @Override - public int getDuration() { - return (int) getMediaPlayer().getLength(); - } - - @Override - public int getCurrentPosition() { - return (int) getMediaPlayer().getTime(); - } - - @Override - public boolean isPlaying() { - return getMediaPlayer().isPlaying(); - } - - @Override - public int getBufferPercentage() { - return 0; - } - - @Override - public int getAudioSessionId() { - return 0; - } - - @Override - public void onEvent(MediaPlayer.Event event) { - - switch(event.type) { - case MediaPlayer.Event.MediaChanged: - showVideo(); - break; - case MediaPlayer.Event.Playing: - showThenAutoHideControls(); - break; - case MediaPlayer.Event.Paused: - showThenAutoHideControls(); - break; - case MediaPlayer.Event.EndReached: - finish(); - break; - case MediaPlayer.Event.Buffering: - // Log.d("BUFFERING", ""+event.getBuffering()); - // TODO Use this and show buffering to users - break; - case MediaPlayer.Event.EncounteredError: - // TODO Handle errors encountered if any - break; - } - - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case android.R.id.home: - finish(); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - @Override - public void onPause() { - super.onPause(); - - videoControls.hide(); - - if (!isChangingConfigurations()) { - pause(); - } - - if (isFinishing()) { - tearDownVideoPlayback(); - } - } - - private void tearDownVideoPlayback() { - getMediaPlayer().stop(); - } - - @Override - protected void onStop() { - super.onStop(); - getSurfaceFrame().removeOnLayoutChangeListener(this); - getMediaPlayer().getVLCVout().detachViews(); - tearDownVideoServiceBind(); - } - - private void tearDownVideoServiceBind() { - unbindService(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (isFinishing()) { - tearDownVideoService(); - } - } - - private void tearDownVideoService() { - Intent intent = new Intent(this, VideoService.class); - stopService(intent); - } - - public static boolean supports(String mime_type) { - String type = mime_type.split("/")[0]; - - return "video".equals(type); - } + switch (CURRENT_SIZE) { + case SURFACE_BEST_FIT: + getMediaPlayer().setAspectRatio(null); + getMediaPlayer().setScale(0); + break; + case SURFACE_FIT_SCREEN: + case SURFACE_FILL: { + Media.VideoTrack vtrack = getMediaPlayer().getCurrentVideoTrack(); + if (vtrack == null) + return; + final boolean videoSwapped = vtrack.orientation == Media.VideoTrack.Orientation.LeftBottom + || vtrack.orientation == Media.VideoTrack.Orientation.RightTop; + if (CURRENT_SIZE == SurfaceSizes.SURFACE_FIT_SCREEN) { + int videoW = vtrack.width; + int videoH = vtrack.height; + if (videoSwapped) { + int swap = videoW; + videoW = videoH; + videoH = swap; + } + if (vtrack.sarNum != vtrack.sarDen) + videoW = videoW * vtrack.sarNum / vtrack.sarDen; + float videoAspectRatio = videoW / (float) videoH; + float displayAspectRatio = displayW / (float) displayH; + float scale; + if (displayAspectRatio >= videoAspectRatio) + scale = displayW / (float) videoW; /* horizontal */ + else + scale = displayH / (float) videoH; /* vertical */ + getMediaPlayer().setScale(scale); + getMediaPlayer().setAspectRatio(null); + } else { + getMediaPlayer().setScale(0); + getMediaPlayer().setAspectRatio(!videoSwapped ? "" + displayW + ":" + displayH + : "" + displayH + ":" + displayW); + } + break; + } + case SURFACE_16_9: + getMediaPlayer().setAspectRatio("16:9"); + getMediaPlayer().setScale(0); + break; + case SURFACE_4_3: + getMediaPlayer().setAspectRatio("4:3"); + getMediaPlayer().setScale(0); + break; + case SURFACE_ORIGINAL: + getMediaPlayer().setAspectRatio(null); + getMediaPlayer().setScale(1); + break; + } + } + + @Override + public void start() { + videoService.playVideo(); + } + + @Override + public boolean canPause() { + return true; + } + + @Override + public void pause() { + videoService.pauseVideo(); + } + + @Override + public boolean canSeekBackward() { + return true; + } + + @Override + public boolean canSeekForward() { + return true; + } + + @Override + public void seekTo(int time) { + getMediaPlayer().setTime(time); + } + + @Override + public int getDuration() { + return (int) getMediaPlayer().getLength(); + } + + @Override + public int getCurrentPosition() { + return (int) getMediaPlayer().getTime(); + } + + @Override + public boolean isPlaying() { + return getMediaPlayer().isPlaying(); + } + + @Override + public int getBufferPercentage() { + return (int) bufferPercent; + } + + @Override + public int getAudioSessionId() { + return 0; + } + + @Override + public void onEvent(MediaPlayer.Event event) { + + switch (event.type) { + case MediaPlayer.Event.MediaChanged: + getVideoMainFrame().setVisibility(View.VISIBLE); + break; + case MediaPlayer.Event.Playing: + getProgressBar().setVisibility(View.INVISIBLE); + showThenAutoHideControls(); + break; + case MediaPlayer.Event.Paused: + showThenAutoHideControls(); + break; + case MediaPlayer.Event.EndReached: + finish(); + break; + case MediaPlayer.Event.Buffering: + bufferPercent = event.getBuffering(); + break; + case MediaPlayer.Event.EncounteredError: + Toast.makeText(this, R.string.message_error_video, Toast.LENGTH_SHORT).show(); + break; + } + + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + finish(); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + public void onPause() { + super.onPause(); + + videoControls.hide(); + + if (!isChangingConfigurations()) { + pause(); + } + + if (isFinishing()) { + tearDownVideoPlayback(); + } + } + + private void tearDownVideoPlayback() { + getMediaPlayer().stop(); + } + + @Override + protected void onStop() { + super.onStop(); + getSurfaceFrame().removeOnLayoutChangeListener(this); + getMediaPlayer().getVLCVout().detachViews(); + tearDownVideoServiceBind(); + } + + private void tearDownVideoServiceBind() { + unbindService(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (isFinishing()) { + tearDownVideoService(); + } + } + + private void tearDownVideoService() { + Intent intent = new Intent(this, VideoService.class); + stopService(intent); + } + + private enum SurfaceSizes { + SURFACE_BEST_FIT, + SURFACE_FIT_SCREEN, + SURFACE_FILL, + SURFACE_16_9, + SURFACE_4_3, + SURFACE_ORIGINAL; + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ServerFileWebActivity.java b/src/main/java/org/amahi/anywhere/activity/ServerFileWebActivity.java index 15af33ae0..5ad21614e 100644 --- a/src/main/java/org/amahi/anywhere/activity/ServerFileWebActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/ServerFileWebActivity.java @@ -20,8 +20,6 @@ package org.amahi.anywhere.activity; import android.content.ComponentName; -import android.content.Intent; -import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; import android.support.customtabs.CustomTabsClient; @@ -29,9 +27,6 @@ import android.support.customtabs.CustomTabsServiceConnection; import android.support.customtabs.CustomTabsSession; import android.support.v7.app.AppCompatActivity; -import android.view.MenuItem; -import android.webkit.WebView; -import android.webkit.WebViewClient; import org.amahi.anywhere.AmahiApplication; import org.amahi.anywhere.R; @@ -39,7 +34,6 @@ import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; import org.amahi.anywhere.util.Intents; -import org.amahi.anywhere.util.ViewDirector; import java.util.Arrays; import java.util.HashSet; @@ -51,97 +45,96 @@ * Web activity. Shows web resources such as SVG and HTML files. * Backed up by {@link android.webkit.WebView}. */ -public class ServerFileWebActivity extends AppCompatActivity -{ - private static final Set SUPPORTED_FORMATS; - CustomTabsClient mCustomTabsClient; - CustomTabsSession mCustomTabsSession; - CustomTabsServiceConnection mCustomTabsServiceConnection; - CustomTabsIntent mCustomTabsIntent; - - static { - SUPPORTED_FORMATS = new HashSet(Arrays.asList( - "image/svg+xml", - "text/html", - "text/plain" - )); - } - - @Inject - ServerClient serverClient; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_server_file_web); - - setUpInjections(); - - setUpWebResource(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpWebResource(Bundle state) { - setUpWebResourceContent(state); - } - - private void setUpWebResourceContent(Bundle state) { - if (!isWebResourceStateValid(state)){ - setUpCustomTabs(); - } - } - - private void setUpCustomTabs(){ - - mCustomTabsServiceConnection = new CustomTabsServiceConnection() { - @Override - public void onCustomTabsServiceConnected(ComponentName componentName, CustomTabsClient customTabsClient) { - mCustomTabsClient= customTabsClient; - mCustomTabsClient.warmup(0L); - mCustomTabsSession = mCustomTabsClient.newSession(null); - } - - @Override - public void onServiceDisconnected(ComponentName name) { - mCustomTabsClient= null; - } - }; - - CustomTabsClient.bindCustomTabsService(this, getPackageName(), mCustomTabsServiceConnection); - - mCustomTabsIntent = new CustomTabsIntent.Builder(mCustomTabsSession) - .setShowTitle(true) - .build(); - - mCustomTabsIntent.launchUrl(this,getWebResourceUri()); - } - - private boolean isWebResourceStateValid(Bundle state) { - return state != null; - } - - private Uri getWebResourceUri() { - return serverClient.getFileUri(getShare(), getFile()); - } - - private ServerShare getShare() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); - } - - private ServerFile getFile() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); - } - - public static boolean supports(String mime_type) { - return SUPPORTED_FORMATS.contains(mime_type); - } - - @Override - protected void onResume() { - super.onResume(); - onBackPressed(); - } +public class ServerFileWebActivity extends AppCompatActivity { + private static final Set SUPPORTED_FORMATS; + + static { + SUPPORTED_FORMATS = new HashSet(Arrays.asList( + "image/svg+xml", + "text/html", + "text/plain" + )); + } + + CustomTabsClient mCustomTabsClient; + CustomTabsSession mCustomTabsSession; + CustomTabsServiceConnection mCustomTabsServiceConnection; + CustomTabsIntent mCustomTabsIntent; + @Inject + ServerClient serverClient; + + public static boolean supports(String mime_type) { + return SUPPORTED_FORMATS.contains(mime_type); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_file_web); + + setUpInjections(); + + setUpWebResource(savedInstanceState); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpWebResource(Bundle state) { + setUpWebResourceContent(state); + } + + private void setUpWebResourceContent(Bundle state) { + if (!isWebResourceStateValid(state)) { + setUpCustomTabs(); + } + } + + private void setUpCustomTabs() { + + mCustomTabsServiceConnection = new CustomTabsServiceConnection() { + @Override + public void onCustomTabsServiceConnected(ComponentName componentName, CustomTabsClient customTabsClient) { + mCustomTabsClient = customTabsClient; + mCustomTabsClient.warmup(0L); + mCustomTabsSession = mCustomTabsClient.newSession(null); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mCustomTabsClient = null; + } + }; + + CustomTabsClient.bindCustomTabsService(this, getPackageName(), mCustomTabsServiceConnection); + + mCustomTabsIntent = new CustomTabsIntent.Builder(mCustomTabsSession) + .setShowTitle(true) + .build(); + + mCustomTabsIntent.launchUrl(this, getWebResourceUri()); + } + + private boolean isWebResourceStateValid(Bundle state) { + return state != null; + } + + private Uri getWebResourceUri() { + return serverClient.getFileUri(getShare(), getFile()); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private ServerFile getFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + @Override + protected void onResume() { + super.onResume(); + onBackPressed(); + } } diff --git a/src/main/java/org/amahi/anywhere/activity/ServerFilesActivity.java b/src/main/java/org/amahi/anywhere/activity/ServerFilesActivity.java index 781d21d46..0d6cf7179 100644 --- a/src/main/java/org/amahi/anywhere/activity/ServerFilesActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/ServerFilesActivity.java @@ -19,13 +19,27 @@ package org.amahi.anywhere.activity; +import android.Manifest; import android.app.DialogFragment; -import android.support.v4.app.Fragment; +import android.app.ProgressDialog; +import android.content.DialogInterface; import android.content.Intent; +import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.Environment; +import android.provider.MediaStore; +import android.support.annotation.NonNull; +import android.support.annotation.RequiresApi; +import android.support.design.widget.FloatingActionButton; +import android.support.design.widget.Snackbar; +import android.support.v4.app.Fragment; +import android.support.v4.content.FileProvider; +import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.view.MenuItem; +import android.view.View; import com.squareup.otto.Subscribe; @@ -35,244 +49,510 @@ import org.amahi.anywhere.bus.FileDownloadedEvent; import org.amahi.anywhere.bus.FileOpeningEvent; import org.amahi.anywhere.bus.ServerFileSharingEvent; -import org.amahi.anywhere.fragment.ServerFileDownloadingFragment; +import org.amahi.anywhere.bus.ServerFileUploadCompleteEvent; +import org.amahi.anywhere.bus.ServerFileUploadProgressEvent; +import org.amahi.anywhere.bus.UploadClickEvent; import org.amahi.anywhere.fragment.GooglePlaySearchFragment; +import org.amahi.anywhere.fragment.ServerFileDownloadingFragment; +import org.amahi.anywhere.fragment.ServerFilesFragment; +import org.amahi.anywhere.fragment.UploadBottomSheet; +import org.amahi.anywhere.model.UploadOption; import org.amahi.anywhere.server.client.ServerClient; import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.util.Android; import org.amahi.anywhere.util.Fragments; import org.amahi.anywhere.util.Intents; import org.amahi.anywhere.util.Mimes; +import java.io.File; +import java.io.IOException; +import java.util.Date; import java.util.List; import javax.inject.Inject; +import pub.devrel.easypermissions.AppSettingsDialog; +import pub.devrel.easypermissions.EasyPermissions; +import timber.log.Timber; + /** * Files activity. Shows files navigation and operates basic file actions, * such as opening and sharing. * The files navigation itself is done via {@link org.amahi.anywhere.fragment.ServerFilesFragment}. */ -public class ServerFilesActivity extends AppCompatActivity -{ - private static final class State - { - private State() { - } - - public static final String FILE = "file"; - public static final String FILE_ACTION = "file_action"; - } - - private static enum FileAction - { - OPEN, SHARE - } - - @Inject - ServerClient serverClient; - - private ServerFile file; - private FileAction fileAction; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_server_files); - - setUpInjections(); - - setUpHomeNavigation(); - - setUpFiles(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpHomeNavigation() { - getSupportActionBar().setHomeButtonEnabled(true); - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } - - private void setUpFiles(Bundle state) { - setUpFilesTitle(); - setUpFilesFragment(); - setUpFilesState(state); - } - - private void setUpFilesTitle() { - getSupportActionBar().setTitle(getShare().getName()); - } - - @Override - public void onBackPressed() { - super.onBackPressed(); - setUpFilesTitle(); - } - - private ServerShare getShare() { - return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); - } - - private void setUpFilesFragment() { - Fragments.Operator.at(this).set(buildFilesFragment(getShare(), null), R.id.container_files); - } - - private Fragment buildFilesFragment(ServerShare share, ServerFile directory) { - return Fragments.Builder.buildServerFilesFragment(share, directory); - } - - private void setUpFilesState(Bundle state) { - if (isFilesStateValid(state)) { - this.file = state.getParcelable(State.FILE); - this.fileAction = (FileAction) state.getSerializable(State.FILE_ACTION); - } - } - - private boolean isFilesStateValid(Bundle state) { - return (state != null) && state.containsKey(State.FILE) && state.containsKey(State.FILE_ACTION); - } - - @Subscribe - public void onFileOpening(FileOpeningEvent event) { - this.file = event.getFile(); - this.fileAction = FileAction.OPEN; - - setUpFile(event.getShare(), event.getFiles(), event.getFile()); - } - - private void setUpFile(ServerShare share, List files, ServerFile file) { - if (isDirectory(file)) { - setUpFilesFragment(share, file); - } else { - setUpFileActivity(share, files, file); - } - } - - private boolean isDirectory(ServerFile file) { - return Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY; - } - - private void setUpFilesFragment(ServerShare share, ServerFile directory) { - Fragments.Operator.at(this).replaceBackstacked(buildFilesFragment(share, directory), R.id.container_files); - } - - private void setUpFileActivity(ServerShare share, List files, ServerFile file) { - if (Intents.Builder.with(this).isServerFileSupported(file)) { - startFileActivity(share, files, file); - return; - } - - if (Intents.Builder.with(this).isServerFileOpeningSupported(file)) { - startFileOpeningActivity(share, file); - return; - } - - showGooglePlaySearchFragment(file); - } - - private void startFileActivity(ServerShare share, List files, ServerFile file) { - Intent intent = Intents.Builder.with(this).buildServerFileIntent(share, files, file); - startActivity(intent); - } - - private void startFileOpeningActivity(ServerShare share, ServerFile file) { - startFileDownloading(share, file); - } - - private void startFileDownloading(ServerShare share, ServerFile file) { - showFileDownloadingFragment(share, file); - } - - private void showFileDownloadingFragment(ServerShare share, ServerFile file) { - DialogFragment fragment = ServerFileDownloadingFragment.newInstance(share, file); - fragment.show(getFragmentManager(), ServerFileDownloadingFragment.TAG); - } - - @Subscribe - public void onFileDownloaded(FileDownloadedEvent event) { - finishFileDownloading(event.getFileUri()); - } - - private void finishFileDownloading(Uri fileUri) { - switch (fileAction) { - case OPEN: - startFileOpeningActivity(file, fileUri); - break; - - case SHARE: - startFileSharingActivity(file, fileUri); - break; - - default: - break; - } - } - - private void startFileOpeningActivity(ServerFile file, Uri fileUri) { - Intent intent = Intents.Builder.with(this).buildServerFileOpeningIntent(file, fileUri); - startActivity(intent); - } - - private void startFileSharingActivity(ServerFile file, Uri fileUri) { - Intent intent = Intents.Builder.with(this).buildServerFileSharingIntent(file, fileUri); - startActivity(intent); - } - - private void showGooglePlaySearchFragment(ServerFile file) { - GooglePlaySearchFragment fragment = GooglePlaySearchFragment.newInstance(file); - fragment.show(getFragmentManager(), GooglePlaySearchFragment.TAG); - } - - @Subscribe - public void onFileSharing(ServerFileSharingEvent event) { - this.file = event.getFile(); - this.fileAction = FileAction.SHARE; - - startFileSharingActivity(event.getShare(), event.getFile()); - } - - private void startFileSharingActivity(ServerShare share, ServerFile file) { - startFileDownloading(share, file); - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - @Override - protected void onResume() { - super.onResume(); - - BusProvider.getBus().register(this); - } - - @Override - protected void onPause() { - super.onPause(); - - BusProvider.getBus().unregister(this); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - tearDownFilesState(outState); - } - - private void tearDownFilesState(Bundle state) { - state.putParcelable(State.FILE, file); - state.putSerializable(State.FILE_ACTION, fileAction); - } +public class ServerFilesActivity extends AppCompatActivity implements EasyPermissions.PermissionCallbacks { + + private static final int FILE_UPLOAD_PERMISSION = 102; + private static final int CAMERA_PERMISSION = 103; + private static final int REQUEST_UPLOAD_IMAGE = 201; + private static final int REQUEST_CAMERA_IMAGE = 202; + @Inject + ServerClient serverClient; + private ServerFile file; + private FileAction fileAction; + private ProgressDialog uploadProgressDialog; + private File cameraImage; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_files); + + setUpInjections(); + + setUpHomeNavigation(); + + setUpFiles(savedInstanceState); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpFiles(Bundle state) { + setUpFilesTitle(); + setUpUploadFAB(); + setUpUploadDialog(); + setUpFilesFragment(); + setUpFilesState(state); + } + + private void setUpFilesTitle() { + getSupportActionBar().setTitle(getShare().getName()); + } + + private void setUpUploadFAB() { + final FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab_upload); + fab.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + new UploadBottomSheet().show(getSupportFragmentManager(), "upload_dialog"); + } + }); + } + + private void setUpUploadDialog() { + uploadProgressDialog = new ProgressDialog(this); + uploadProgressDialog.setTitle(getString(R.string.message_file_upload_title)); + uploadProgressDialog.setCancelable(false); + uploadProgressDialog.setIndeterminate(false); + uploadProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + setUpFilesTitle(); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private void setUpFilesFragment() { + Fragments.Operator.at(this).set(buildFilesFragment(getShare(), null), R.id.container_files); + } + + private Fragment buildFilesFragment(ServerShare share, ServerFile directory) { + return Fragments.Builder.buildServerFilesFragment(share, directory); + } + + private void setUpFilesState(Bundle state) { + if (isFilesStateValid(state)) { + this.file = state.getParcelable(State.FILE); + this.fileAction = (FileAction) state.getSerializable(State.FILE_ACTION); + } + } + + private boolean isFilesStateValid(Bundle state) { + return (state != null) && state.containsKey(State.FILE) && state.containsKey(State.FILE_ACTION); + } + + @Subscribe + public void onFileOpening(FileOpeningEvent event) { + this.file = event.getFile(); + this.fileAction = FileAction.OPEN; + + setUpFile(event.getShare(), event.getFiles(), event.getFile()); + } + + private void setUpFile(ServerShare share, List files, ServerFile file) { + if (isDirectory(file)) { + setUpFilesFragment(share, file); + } else { + setUpFileActivity(share, files, file); + } + } + + private boolean isDirectory(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY; + } + + private void setUpFilesFragment(ServerShare share, ServerFile directory) { + Fragments.Operator.at(this).replaceBackstacked(buildFilesFragment(share, directory), R.id.container_files); + } + + private void setUpFileActivity(ServerShare share, List files, ServerFile file) { + if (Intents.Builder.with(this).isServerFileSupported(file)) { + startFileActivity(share, files, file); + return; + } + + if (Intents.Builder.with(this).isServerFileOpeningSupported(file)) { + startFileOpeningActivity(share, file); + return; + } + + showGooglePlaySearchFragment(file); + } + + private void startFileActivity(ServerShare share, List files, ServerFile file) { + Intent intent = Intents.Builder.with(this).buildServerFileIntent(share, files, file); + startActivity(intent); + } + + private void startFileOpeningActivity(ServerShare share, ServerFile file) { + startFileDownloading(share, file); + } + + private void startFileDownloading(ServerShare share, ServerFile file) { + showFileDownloadingFragment(share, file); + } + + private void showFileDownloadingFragment(ServerShare share, ServerFile file) { + DialogFragment fragment = ServerFileDownloadingFragment.newInstance(share, file); + fragment.show(getFragmentManager(), ServerFileDownloadingFragment.TAG); + } + + @Subscribe + public void onFileDownloaded(FileDownloadedEvent event) { + finishFileDownloading(event.getFileUri()); + } + + private void finishFileDownloading(Uri fileUri) { + switch (fileAction) { + case OPEN: + startFileOpeningActivity(file, fileUri); + break; + + case SHARE: + startFileSharingActivity(file, fileUri); + break; + + default: + break; + } + } + + private void startFileOpeningActivity(ServerFile file, Uri fileUri) { + Intent intent = Intents.Builder.with(this).buildServerFileOpeningIntent(file, fileUri); + startActivity(intent); + } + + private void startFileSharingActivity(ServerFile file, Uri fileUri) { + Intent intent = Intents.Builder.with(this).buildServerFileSharingIntent(file, fileUri); + startActivity(intent); + } + + private void showGooglePlaySearchFragment(ServerFile file) { + GooglePlaySearchFragment fragment = GooglePlaySearchFragment.newInstance(file); + fragment.show(getFragmentManager(), GooglePlaySearchFragment.TAG); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + // Forward results to EasyPermissions + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @Override + public void onPermissionsGranted(int requestCode, List perms) { + if (requestCode == FILE_UPLOAD_PERMISSION) { + showFileChooser(); + } else if (requestCode == CAMERA_PERMISSION) { + openCamera(); + } + } + + @Override + public void onPermissionsDenied(int requestCode, List perms) { + if (requestCode == FILE_UPLOAD_PERMISSION) { + showPermissionSnackBar(getString(R.string.file_upload_permission_denied)); + } else if (requestCode == CAMERA_PERMISSION) { + showPermissionSnackBar(getString(R.string.file_upload_permission_denied)); + } + } + + private View getParentView() { + return findViewById(R.id.coordinator_files); + } + + private void showPermissionSnackBar(String message) { + Snackbar.make(getParentView(), message, Snackbar.LENGTH_LONG) + .setAction(R.string.menu_settings, new View.OnClickListener() { + @Override + public void onClick(View v) { + new AppSettingsDialog.Builder(ServerFilesActivity.this).build().show(); + } + }) + .show(); + } + + @Subscribe + public void onUploadOptionClick(UploadClickEvent event) { + int option = event.getUploadOption(); + switch (option) { + case UploadOption.CAMERA: + if (Android.isPermissionRequired()) { + checkCameraPermissions(); + } else { + openCamera(); + } + break; + case UploadOption.FILE: + if (Android.isPermissionRequired()) { + checkFileReadPermissions(); + } else { + showFileChooser(); + } + break; + } + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private void checkCameraPermissions() { + String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; + if (EasyPermissions.hasPermissions(this, perms)) { + openCamera(); + } else { + EasyPermissions.requestPermissions(this, getString(R.string.camera_permission), + CAMERA_PERMISSION, perms); + } + } + + private void openCamera() { + Intent cameraIntent = Intents.Builder.with(this).buildCameraIntent(); + if (cameraIntent.resolveActivity(getPackageManager()) != null) { + cameraImage = null; + try { + cameraImage = createImageFile(); + Uri photoURI = FileProvider.getUriForFile(this, + "org.amahi.anywhere.fileprovider", cameraImage); + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI); + startActivityForResult(cameraIntent, REQUEST_CAMERA_IMAGE); + } catch (IOException ex) { + Timber.d(ex); + } + } + } + + private File createImageFile() throws IOException { + String timeStamp = String.valueOf(new Date().getTime()); + String imageFileName = "photo-" + timeStamp; + File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); + return File.createTempFile(imageFileName, ".jpg", storageDir); + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private void checkFileReadPermissions() { + String[] perms = {Manifest.permission.READ_EXTERNAL_STORAGE}; + if (EasyPermissions.hasPermissions(this, perms)) { + showFileChooser(); + } else { + EasyPermissions.requestPermissions(this, getString(R.string.file_upload_permission), + FILE_UPLOAD_PERMISSION, perms); + } + } + + private void showFileChooser() { + Intent intent = Intents.Builder.with(this).buildMediaPickerIntent(); + this.startActivityForResult(intent, REQUEST_UPLOAD_IMAGE); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + switch (requestCode) { + case REQUEST_UPLOAD_IMAGE: + if (data != null) { + Uri selectedImageUri = data.getData(); + String filePath = querySelectedImagePath(selectedImageUri); + if (filePath != null) { + File file = new File(filePath); + if (file.exists()) { + ServerFilesFragment fragment = (ServerFilesFragment) + getSupportFragmentManager() + .findFragmentById(R.id.container_files); + if (fragment.checkForDuplicateFile(file.getName())) { + showDuplicateFileUploadDialog(file); + } else { + uploadFile(file); + } + } + } + } + break; + case REQUEST_CAMERA_IMAGE: + if (cameraImage.exists()) { + uploadFile(cameraImage); + } + break; + } + } + } + + private String querySelectedImagePath(Uri selectedImageUri) { + String filePath = null; + if ("content".equals(selectedImageUri.getScheme())) { + String[] filePathColumn = {MediaStore.Images.Media.DATA}; + Cursor cursor = this.getContentResolver() + .query(selectedImageUri, filePathColumn, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + int columnIndex = cursor.getColumnIndex(filePathColumn[0]); + filePath = cursor.getString(columnIndex); + cursor.close(); + } + } else { + filePath = selectedImageUri.toString(); + } + return filePath; + } + + private void showDuplicateFileUploadDialog(final File file) { + new AlertDialog.Builder(this) + .setTitle(R.string.message_duplicate_file_upload) + .setMessage(getString(R.string.message_duplicate_file_upload_body, file.getName())) + .setPositiveButton(R.string.button_yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + uploadFile(file); + } + }) + .setNegativeButton(R.string.button_no, null) + .show(); + } + + private void uploadFile(File uploadFile) { + serverClient.uploadFile(0, uploadFile, getShare(), file); + uploadProgressDialog.show(); + } + + @Subscribe + public void onFileUploadProgressEvent(ServerFileUploadProgressEvent fileUploadProgressEvent) { + if (uploadProgressDialog.isShowing()) { + uploadProgressDialog.setProgress(fileUploadProgressEvent.getProgress()); + } + } + + @Subscribe + public void onFileUploadCompleteEvent(ServerFileUploadCompleteEvent event) { + uploadProgressDialog.dismiss(); + if (event.wasUploadSuccessful()) { + Fragments.Operator.at(this).replace(buildFilesFragment(getShare(), file), R.id.container_files); + Snackbar.make(getParentView(), R.string.message_file_upload_complete, Snackbar.LENGTH_LONG).show(); + if (cameraImage != null && cameraImage.exists()) { + clearCameraImage(); + } + } else { + Snackbar snackbar = Snackbar.make(getParentView(), R.string.message_file_upload_error, Snackbar.LENGTH_LONG); + if (cameraImage != null && cameraImage.exists()) { + snackbar + .setAction(R.string.button_retry, new View.OnClickListener() { + @Override + public void onClick(View v) { + uploadFile(cameraImage); + } + }) + .addCallback(new Snackbar.Callback() { + @Override + public void onDismissed(Snackbar transientBottomBar, int event) { + super.onDismissed(transientBottomBar, event); + if (event != DISMISS_EVENT_ACTION) { + clearCameraImage(); + } + } + }); + } + snackbar.show(); + } + } + + private void clearCameraImage() { + //noinspection ResultOfMethodCallIgnored + cameraImage.delete(); + cameraImage = null; + } + + @Subscribe + public void onFileSharing(ServerFileSharingEvent event) { + this.file = event.getFile(); + this.fileAction = FileAction.SHARE; + + startFileSharingActivity(event.getShare(), event.getFile()); + } + + private void startFileSharingActivity(ServerShare share, ServerFile file) { + startFileDownloading(share, file); + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + protected void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + protected void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + tearDownFilesState(outState); + } + + private void tearDownFilesState(Bundle state) { + state.putParcelable(State.FILE, file); + state.putSerializable(State.FILE_ACTION, fileAction); + } + + private enum FileAction { + OPEN, SHARE; + } + + private static final class State { + + public static final String FILE = "file"; + public static final String FILE_ACTION = "file_action"; + + private State() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/activity/SettingsActivity.java b/src/main/java/org/amahi/anywhere/activity/SettingsActivity.java index 75980cffb..f7537f14f 100644 --- a/src/main/java/org/amahi/anywhere/activity/SettingsActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/SettingsActivity.java @@ -19,52 +19,79 @@ package org.amahi.anywhere.activity; -import android.os.Bundle; import android.app.FragmentManager; import android.app.FragmentTransaction; +import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.view.MenuItem; +import com.squareup.otto.Subscribe; + import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.UploadSettingsOpeningEvent; import org.amahi.anywhere.fragment.SettingsFragment; +import org.amahi.anywhere.fragment.UploadSettingsFragment; /** * Settings activity. Shows application's settings. * Settings itself are provided via {@link org.amahi.anywhere.fragment.SettingsFragment}. */ -public class SettingsActivity extends AppCompatActivity -{ - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setUpHomeNavigation(); - - setContentView(R.layout.activity_settings); - - setUpSettingsFragment(); - } - - private void setUpHomeNavigation() { - getSupportActionBar().setHomeButtonEnabled(true); - getSupportActionBar().setIcon(R.drawable.ic_launcher); - } - - private void setUpSettingsFragment() { - FragmentManager fragmentManager = getFragmentManager(); - fragmentManager.beginTransaction().setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - .replace(R.id.settings_container,new SettingsFragment()).commit(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case android.R.id.home: - finish(); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } +public class SettingsActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setUpHomeNavigation(); + + setContentView(R.layout.activity_settings); + + setUpSettingsFragment(); + } + + private void setUpHomeNavigation() { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setIcon(R.drawable.ic_launcher); + } + + private void setUpSettingsFragment() { + FragmentManager fragmentManager = getFragmentManager(); + fragmentManager.beginTransaction().setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.settings_container, new SettingsFragment()).commit(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Subscribe + public void onUploadSettingsOpenEvent(UploadSettingsOpeningEvent event) { + getFragmentManager().beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.settings_container, new UploadSettingsFragment()) + .addToBackStack(null) + .commit(); + } + + @Override + protected void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + protected void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } } diff --git a/src/main/java/org/amahi/anywhere/activity/SplashActivity.java b/src/main/java/org/amahi/anywhere/activity/SplashActivity.java new file mode 100644 index 000000000..7ce256805 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/activity/SplashActivity.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.activity; + +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; + +public class SplashActivity extends AppCompatActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + startActivity(new Intent(this, NavigationActivity.class)); + finish(); + } +} diff --git a/src/main/java/org/amahi/anywhere/activity/WebViewActivity.java b/src/main/java/org/amahi/anywhere/activity/WebViewActivity.java index 379b92090..2316271bb 100644 --- a/src/main/java/org/amahi/anywhere/activity/WebViewActivity.java +++ b/src/main/java/org/amahi/anywhere/activity/WebViewActivity.java @@ -19,16 +19,16 @@ protected void onCreate(Bundle savedInstanceState) { setUpHomeNavigation(); setContentView(R.layout.activity_web_view); - webView=(WebView)findViewById(R.id.webview); + webView = (WebView) findViewById(R.id.webview); loadWebView("https://www.amahi.org/android"); } - private void loadWebView(String url){ + private void loadWebView(String url) { webView.setWebViewClient(new WebViewClient()); - WebSettings settings=webView.getSettings(); + WebSettings settings = webView.getSettings(); settings.setLoadWithOverviewMode(true); settings.setBuiltInZoomControls(true); @@ -54,4 +54,4 @@ public boolean onOptionsItemSelected(MenuItem menuItem) { return super.onOptionsItemSelected(menuItem); } } -} \ No newline at end of file +} diff --git a/src/main/java/org/amahi/anywhere/adapter/FilesFilterBaseAdapter.java b/src/main/java/org/amahi/anywhere/adapter/FilesFilterBaseAdapter.java index 0185a1187..8b2c93061 100644 --- a/src/main/java/org/amahi/anywhere/adapter/FilesFilterBaseAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/FilesFilterBaseAdapter.java @@ -19,9 +19,11 @@ package org.amahi.anywhere.adapter; +import android.content.Context; import android.graphics.Color; +import android.media.MediaMetadataRetriever; import android.net.Uri; -import android.support.annotation.DrawableRes; +import android.os.AsyncTask; import android.text.style.ForegroundColorSpan; import android.view.LayoutInflater; import android.view.View; @@ -41,6 +43,7 @@ import org.amahi.anywhere.util.Mimes; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; /** @@ -51,17 +54,13 @@ public abstract class FilesFilterBaseAdapter extends BaseAdapter implements Filterable { + static final ForegroundColorSpan fcs = new ForegroundColorSpan(Color.parseColor("#be5e00")); + static String queryString; LayoutInflater layoutInflater; ServerClient serverClient; - ServerShare serverShare; - List files; List filteredFiles; - - static String queryString; - static final ForegroundColorSpan fcs = new ForegroundColorSpan(Color.parseColor("#be5e00")); - private FilesFilter filesFilter; private onFilterListChange onFilterListChange; @@ -69,10 +68,6 @@ public abstract class FilesFilterBaseAdapter extends BaseAdapter implements Filt abstract View newView(ViewGroup container); - public interface onFilterListChange { - void isListEmpty(boolean empty); - } - public void setFilterListChangeListener(T t) { this.onFilterListChange = t; } @@ -113,6 +108,11 @@ public void replaceWith(ServerShare serverShare, List files) { notifyDataSetChanged(); } + public void removeFile(int position) { + this.files.remove(position); + notifyDataSetChanged(); + } + @Override public Filter getFilter() { if (filesFilter == null) { @@ -125,6 +125,23 @@ public List getItems() { return files; } + void setUpImageIcon(ServerFile file, ImageView fileIconView) { + Glide.with(fileIconView.getContext()) + .load(getImageUri(file)) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .centerCrop() + .placeholder(Mimes.getFileIcon(file)) + .into(fileIconView); + } + + private Uri getImageUri(ServerFile file) { + return serverClient.getFileUri(serverShare, file); + } + + public interface onFilterListChange { + void isListEmpty(boolean empty); + } + private class FilesFilter extends Filter { @Override protected FilterResults performFiltering(CharSequence constraint) { @@ -157,52 +174,45 @@ protected void publishResults(CharSequence charSequence, FilterResults filterRes } } - @DrawableRes - static int getFileIcon(ServerFile file) { - switch (Mimes.match(file.getMime())) { - case Mimes.Type.ARCHIVE: - return R.drawable.ic_file_archive; - - case Mimes.Type.AUDIO: - return R.drawable.ic_file_audio; - - case Mimes.Type.CODE: - return R.drawable.ic_file_code; - - case Mimes.Type.DOCUMENT: - return R.drawable.ic_file_text; + class AlbumArtFetcher extends AsyncTask { + private final ImageView imageView; + private final Uri audioUri; + private final Context applicationContext; - case Mimes.Type.DIRECTORY: - return R.drawable.ic_file_directory; - - case Mimes.Type.IMAGE: - return R.drawable.ic_file_image; - - case Mimes.Type.PRESENTATION: - return R.drawable.ic_file_presentation; - - case Mimes.Type.SPREADSHEET: - return R.drawable.ic_file_spreadsheet; - - case Mimes.Type.VIDEO: - return R.drawable.ic_file_video; + AlbumArtFetcher(ImageView imageView, Uri audioUri, Context applicationContext) { + this.imageView = imageView; + this.audioUri = audioUri; + this.applicationContext = applicationContext; + } - default: - return R.drawable.ic_file_generic; + @Override + protected byte[] doInBackground(Void... params) { + try { + MediaMetadataRetriever audioMetadataRetriever = new MediaMetadataRetriever(); + audioMetadataRetriever.setDataSource(audioUri.toString(), new HashMap()); + return extractAlbumArt(audioMetadataRetriever); + } catch (Exception e) { + e.printStackTrace(); + return null; + } } - } - void setUpImageIcon(ServerFile file, ImageView fileIconView) { - Glide.with(fileIconView.getContext()) - .load(getImageUri(file)) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .centerCrop() - .placeholder(getFileIcon(file)) - .into(fileIconView); - } + private byte[] extractAlbumArt(MediaMetadataRetriever audioMetadataRetriever) { + return audioMetadataRetriever.getEmbeddedPicture(); + } - private Uri getImageUri(ServerFile file) { - return serverClient.getFileUri(serverShare, file); + @Override + protected void onPostExecute(byte[] bitmap) { + if (bitmap != null) { + Glide.with(applicationContext) + .load(bitmap) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .centerCrop() + .placeholder(R.drawable.ic_file_audio) + .error(R.drawable.ic_file_audio) + .into(imageView); + } + } } - } diff --git a/src/main/java/org/amahi/anywhere/adapter/NavigationDrawerAdapter.java b/src/main/java/org/amahi/anywhere/adapter/NavigationDrawerAdapter.java index 313dcafdb..9fb9745d4 100644 --- a/src/main/java/org/amahi/anywhere/adapter/NavigationDrawerAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/NavigationDrawerAdapter.java @@ -19,17 +19,12 @@ public class NavigationDrawerAdapter extends RecyclerView.Adapter { - public static final class NavigationItems - { - private NavigationItems() { - } - - public static final int SHARES = 0; - public static final int APPS = 1; - } - private final List navigationItems; private Context mContext; + public NavigationDrawerAdapter(Context context, List navigationItems) { + this.navigationItems = navigationItems; + mContext = context; + } public static NavigationDrawerAdapter newLocalAdapter(Context context) { return new NavigationDrawerAdapter(context, Arrays.asList(NavigationDrawerAdapter.NavigationItems.SHARES, NavigationDrawerAdapter.NavigationItems.APPS)); @@ -39,27 +34,14 @@ public static NavigationDrawerAdapter newRemoteAdapter(Context context) { return new NavigationDrawerAdapter(context, Arrays.asList(NavigationDrawerAdapter.NavigationItems.SHARES)); } - public NavigationDrawerAdapter(Context context, List navigationItems){ - this.navigationItems = navigationItems; - mContext = context; - } - - class NavigationDrawerViewHolder extends RecyclerView.ViewHolder{ - TextView titleShare; - NavigationDrawerViewHolder(View itemView) { - super(itemView); - titleShare = (TextView)itemView.findViewById(R.id.text_share_title); - } - } - @Override public NavigationDrawerViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - return new NavigationDrawerViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.view_navigation_item,parent,false)); + return new NavigationDrawerViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.view_navigation_item, parent, false)); } @Override public void onBindViewHolder(NavigationDrawerViewHolder holder, int position) { - holder.titleShare.setText(getNavigationName(mContext,position)); + holder.titleShare.setText(getNavigationName(mContext, position)); } @Override @@ -80,4 +62,20 @@ private String getNavigationName(Context context, int navigationItem) { } } + public static final class NavigationItems { + public static final int SHARES = 0; + public static final int APPS = 1; + private NavigationItems() { + } + } + + class NavigationDrawerViewHolder extends RecyclerView.ViewHolder { + TextView titleShare; + + NavigationDrawerViewHolder(View itemView) { + super(itemView); + titleShare = (TextView) itemView.findViewById(R.id.text_share_title); + } + } + } diff --git a/src/main/java/org/amahi/anywhere/adapter/ServerAppsAdapter.java b/src/main/java/org/amahi/anywhere/adapter/ServerAppsAdapter.java index 484a0f979..ec1bee2a5 100644 --- a/src/main/java/org/amahi/anywhere/adapter/ServerAppsAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/ServerAppsAdapter.java @@ -43,75 +43,75 @@ * Apps adapter. Visualizes web apps * for the {@link org.amahi.anywhere.fragment.ServerAppsFragment}. */ -public class ServerAppsAdapter extends RecyclerView.Adapter -{ - private List apps; - private Context mContext; - - public ServerAppsAdapter(Context context) { - mContext = context; - this.apps = Collections.emptyList(); - } - - class ServerAppsViewHolder extends RecyclerView.ViewHolder{ - TextView text; - ImageView logo; - ServerAppsViewHolder(View itemView) { - super(itemView); - text = (TextView)itemView.findViewById(R.id.text); - logo = (ImageView)itemView.findViewById(R.id.logo); - } - } - - @Override - public ServerAppsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - return new ServerAppsViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.view_server_app_item, parent, false)); - } - - @Override - public void onBindViewHolder(final ServerAppsViewHolder holder, int position) { - holder.text.setText(apps.get(position).getName()); - if(TextUtils.isEmpty(apps.get(position).getLogoUrl())) - holder.logo.setImageResource(R.drawable.ic_app_logo); - else { - Glide - .with(mContext) - .load(apps.get(position).getLogoUrl()) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .fitCenter() - .placeholder(R.drawable.ic_app_logo) - .error(R.drawable.ic_app_logo) - .into(holder.logo); - } - holder.itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - BusProvider.getBus().post(new AppSelectedEvent(apps.get(holder.getAdapterPosition()))); - } - }); - } - - @Override - public int getItemCount() { - return apps.size(); - } - - public void replaceWith(List apps) { - this.apps = apps; - - notifyDataSetChanged(); - } - - public List getItems() { - return apps; - } - - public ServerApp getItem(int position) { - return apps.get(position); - } - - @Override - public long getItemId(int position) { - return position; - } +public class ServerAppsAdapter extends RecyclerView.Adapter { + private List apps; + private Context mContext; + + public ServerAppsAdapter(Context context) { + mContext = context; + this.apps = Collections.emptyList(); + } + + @Override + public ServerAppsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new ServerAppsViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.view_server_app_item, parent, false)); + } + + @Override + public void onBindViewHolder(final ServerAppsViewHolder holder, int position) { + holder.text.setText(apps.get(position).getName()); + if (TextUtils.isEmpty(apps.get(position).getLogoUrl())) + holder.logo.setImageResource(R.drawable.ic_app_logo); + else { + Glide + .with(mContext) + .load(apps.get(position).getLogoUrl()) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .fitCenter() + .placeholder(R.drawable.ic_app_logo) + .error(R.drawable.ic_app_logo) + .into(holder.logo); + } + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + BusProvider.getBus().post(new AppSelectedEvent(apps.get(holder.getAdapterPosition()))); + } + }); + } + + @Override + public int getItemCount() { + return apps.size(); + } + + public void replaceWith(List apps) { + this.apps = apps; + + notifyDataSetChanged(); + } + + public List getItems() { + return apps; + } + + public ServerApp getItem(int position) { + return apps.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + class ServerAppsViewHolder extends RecyclerView.ViewHolder { + TextView text; + ImageView logo; + + ServerAppsViewHolder(View itemView) { + super(itemView); + text = (TextView) itemView.findViewById(R.id.text); + logo = (ImageView) itemView.findViewById(R.id.logo); + } + } } diff --git a/src/main/java/org/amahi/anywhere/adapter/ServerFilesAdapter.java b/src/main/java/org/amahi/anywhere/adapter/ServerFilesAdapter.java index 15c3b06f0..a86fc29b4 100644 --- a/src/main/java/org/amahi/anywhere/adapter/ServerFilesAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/ServerFilesAdapter.java @@ -1,4 +1,3 @@ - /* * Copyright (c) 2014 Amahi * @@ -45,62 +44,69 @@ * Files adapter. Visualizes files * for the {@link org.amahi.anywhere.fragment.ServerFilesFragment}. */ -public class ServerFilesAdapter extends FilesFilterBaseAdapter -{ - private Context context; - - public ServerFilesAdapter(Context context, ServerClient serverClient) { - this.serverClient = serverClient; - this.layoutInflater = LayoutInflater.from(context); - this.context=context; - this.files = Collections.emptyList(); - this.filteredFiles = Collections.emptyList(); - } - - protected View newView(ViewGroup container) { - return layoutInflater.inflate(R.layout.view_server_file_item, container, false); - } - - protected void bindView(ServerFile file, View view) { - ImageView fileIconView = (ImageView) view.findViewById(R.id.icon); - TextView fileTextView = (TextView) view.findViewById(R.id.text); - TextView fileSize = (TextView) view.findViewById(R.id.file_size); - TextView fileLastModified = (TextView) view.findViewById(R.id.last_modified); - LinearLayout moreInfo = (LinearLayout) view.findViewById(R.id.more_info); - - if(Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY){ - moreInfo.setVisibility(View.GONE); - - } else { - moreInfo.setVisibility(View.VISIBLE); - - fileSize.setText(Formatter.formatFileSize(context, getFileSize(file))); - - Date d = getLastModified(file); - SimpleDateFormat dt = new SimpleDateFormat("EEE LLL dd yyyy"); - fileLastModified.setText(dt.format(d)); - } - - SpannableStringBuilder sb = new SpannableStringBuilder(file.getName()); - if(queryString != null && !TextUtils.isEmpty(queryString)) { - int searchMatchPosition = file.getName().toLowerCase().indexOf(queryString.toLowerCase()); - if (searchMatchPosition != -1) - sb.setSpan(fcs, searchMatchPosition, searchMatchPosition + queryString.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - fileTextView.setText(sb); - - if (Mimes.match(file.getMime()) == Mimes.Type.IMAGE) { - setUpImageIcon(file, fileIconView); - } else { - fileIconView.setImageResource(getFileIcon(file)); - } - } - - private long getFileSize(ServerFile file) { - return file.getSize(); - } - - private Date getLastModified(ServerFile file) { - return file.getModificationTime(); - } +public class ServerFilesAdapter extends FilesFilterBaseAdapter { + private Context context; + private Context applicationContext; + + public ServerFilesAdapter(Context context, Context applicationContext, ServerClient serverClient) { + this.serverClient = serverClient; + this.layoutInflater = LayoutInflater.from(context); + this.context = context; + this.applicationContext = applicationContext; + this.files = Collections.emptyList(); + this.filteredFiles = Collections.emptyList(); + } + + protected View newView(ViewGroup container) { + return layoutInflater.inflate(R.layout.view_server_file_item, container, false); + } + + protected void bindView(ServerFile file, View view) { + ImageView fileIconView = (ImageView) view.findViewById(R.id.icon); + TextView fileTextView = (TextView) view.findViewById(R.id.text); + TextView fileSize = (TextView) view.findViewById(R.id.file_size); + TextView fileLastModified = (TextView) view.findViewById(R.id.last_modified); + LinearLayout moreInfo = (LinearLayout) view.findViewById(R.id.more_info); + + if (Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY) { + moreInfo.setVisibility(View.GONE); + + } else { + moreInfo.setVisibility(View.VISIBLE); + + fileSize.setText(Formatter.formatFileSize(context, getFileSize(file))); + + Date d = getLastModified(file); + SimpleDateFormat dt = new SimpleDateFormat("EEE LLL dd yyyy"); + fileLastModified.setText(dt.format(d)); + } + + SpannableStringBuilder sb = new SpannableStringBuilder(file.getName()); + if (queryString != null && !TextUtils.isEmpty(queryString)) { + int searchMatchPosition = file.getName().toLowerCase().indexOf(queryString.toLowerCase()); + if (searchMatchPosition != -1) + sb.setSpan(fcs, searchMatchPosition, searchMatchPosition + queryString.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + fileTextView.setText(sb); + + if (Mimes.match(file.getMime()) == Mimes.Type.IMAGE) { + setUpImageIcon(file, fileIconView); + } else if (Mimes.match(file.getMime()) == Mimes.Type.AUDIO) { + setUpAudioArt(file, fileIconView); + } else { + fileIconView.setImageResource(Mimes.getFileIcon(file)); + } + } + + private long getFileSize(ServerFile file) { + return file.getSize(); + } + + private Date getLastModified(ServerFile file) { + return file.getModificationTime(); + } + + private void setUpAudioArt(ServerFile serverFile, ImageView fileIconView) { + new AlbumArtFetcher(fileIconView, serverClient.getFileUri(serverShare, serverFile), applicationContext).execute(); + } } diff --git a/src/main/java/org/amahi/anywhere/adapter/ServerFilesImagePagerAdapter.java b/src/main/java/org/amahi/anywhere/adapter/ServerFilesImagePagerAdapter.java index 3fa4680f3..67cd2795a 100644 --- a/src/main/java/org/amahi/anywhere/adapter/ServerFilesImagePagerAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/ServerFilesImagePagerAdapter.java @@ -33,25 +33,24 @@ * Image files adapter. Maps {@link org.amahi.anywhere.fragment.ServerFileImageFragment} * for the {@link org.amahi.anywhere.activity.ServerFileImageActivity}. */ -public class ServerFilesImagePagerAdapter extends FragmentStatePagerAdapter -{ - private final ServerShare share; - private final List files; - - public ServerFilesImagePagerAdapter(FragmentManager fragmentManager, ServerShare share, List files) { - super(fragmentManager); - - this.share = share; - this.files = files; - } - - @Override - public int getCount() { - return files.size(); - } - - @Override - public Fragment getItem(int position) { - return Fragments.Builder.buildServerFileImageFragment(share, files.get(position)); - } +public class ServerFilesImagePagerAdapter extends FragmentStatePagerAdapter { + private final ServerShare share; + private final List files; + + public ServerFilesImagePagerAdapter(FragmentManager fragmentManager, ServerShare share, List files) { + super(fragmentManager); + + this.share = share; + this.files = files; + } + + @Override + public int getCount() { + return files.size(); + } + + @Override + public Fragment getItem(int position) { + return Fragments.Builder.buildServerFileImageFragment(share, files.get(position)); + } } diff --git a/src/main/java/org/amahi/anywhere/adapter/ServerFilesMetadataAdapter.java b/src/main/java/org/amahi/anywhere/adapter/ServerFilesMetadataAdapter.java index abf5d51c1..1d649f024 100644 --- a/src/main/java/org/amahi/anywhere/adapter/ServerFilesMetadataAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/ServerFilesMetadataAdapter.java @@ -44,126 +44,122 @@ import java.util.Collections; -public class ServerFilesMetadataAdapter extends FilesFilterBaseAdapter -{ - public static final class Tags - { - private Tags() { - } - - public static final int SHARE = R.id.container_files; - public static final int FILE = R.attr.server_share; - - public static final int FILE_TITLE = R.id.text; - public static final int FILE_ICON = R.id.icon; - } - - public ServerFilesMetadataAdapter(Context context, ServerClient serverClient) { - this.layoutInflater = LayoutInflater.from(context); - - this.serverClient = serverClient; - - this.files = Collections.emptyList(); - this.filteredFiles = Collections.emptyList(); - - BusProvider.getBus().register(this); - } - - protected View newView(ViewGroup container) { - View fileView = layoutInflater.inflate(R.layout.view_server_file_metadata_item, container, false); - - fileView.setTag(Tags.FILE_TITLE, fileView.findViewById(R.id.text)); - fileView.setTag(Tags.FILE_ICON, fileView.findViewById(R.id.icon)); - - return fileView; - } - - protected void bindView(ServerFile file, View fileView) { - unbindFileView(file, fileView); - - ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); - if (Mimes.match(file.getMime()) != Mimes.Type.VIDEO) { - bindFileView(file, fileView); - } else { - if(!file.isMetaDataFetched()) { - bindFileMetadataView(file, fileView); - } else { - bindView(file, file.getFileMetadata(), fileView); - } - } - if (Mimes.match(file.getMime()) == Mimes.Type.IMAGE) { - setUpImageIcon(file, fileIcon); - } - } - - private void unbindFileView(ServerFile file, View fileView) { - TextView fileTitle = (TextView) fileView.getTag(Tags.FILE_TITLE); - ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); - - fileTitle.setText(null); - fileTitle.setBackgroundResource(android.R.color.transparent); - - fileIcon.setImageResource(getFileIcon(file)); - fileIcon.setBackgroundResource(R.color.background_secondary); - } - - private void bindFileView(ServerFile file, View fileView) { - TextView fileTitle = (TextView) fileView.getTag(Tags.FILE_TITLE); - ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); - - SpannableStringBuilder sb = new SpannableStringBuilder(file.getName()); - if(queryString != null && !TextUtils.isEmpty(queryString)) { - int searchMatchPosition = file.getName().toLowerCase().indexOf(queryString.toLowerCase()); - if (searchMatchPosition != -1) - sb.setSpan(fcs, searchMatchPosition, searchMatchPosition + queryString.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - fileTitle.setText(sb); - fileTitle.setBackgroundResource(R.color.background_transparent_secondary); - - fileIcon.setImageResource(getFileIcon(file)); - fileIcon.setBackgroundResource(R.color.background_secondary); - } - - private void bindFileMetadataView(ServerFile file, View fileView) { - fileView.setTag(Tags.SHARE, serverShare); - fileView.setTag(Tags.FILE, file); - - new FileMetadataRetrievingTask(serverClient, fileView).execute(); - } - - @Subscribe - public void onFileMetadataRetrieved(FileMetadataRetrievedEvent event) { - event.getFile().setMetaDataFetched(true); - bindView(event.getFile(), event.getFileMetadata(), event.getFileView()); - } - - private void bindView(ServerFile file, ServerFileMetadata fileMetadata, View fileView) { - if (fileMetadata == null) { - bindFileView(file, fileView); - } else { - file.setFileMetadata(fileMetadata); - bindFileMetadataView(file, fileMetadata, fileView); - } - } - - private void bindFileMetadataView(ServerFile file, ServerFileMetadata fileMetadata, View fileView) { - TextView fileTitle = (TextView) fileView.getTag(Tags.FILE_TITLE); - ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); - - fileTitle.setText(null); - fileTitle.setBackgroundResource(android.R.color.transparent); - - Glide.with(fileView.getContext()) - .load(fileMetadata.getArtworkUrl()) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .centerCrop() - .fitCenter() - .placeholder(getFileIcon(file)) - .error(getFileIcon(file)) - .into(fileIcon); - } - - public void tearDownCallbacks() { - BusProvider.getBus().unregister(this); - } +public class ServerFilesMetadataAdapter extends FilesFilterBaseAdapter { + public ServerFilesMetadataAdapter(Context context, ServerClient serverClient) { + this.layoutInflater = LayoutInflater.from(context); + + this.serverClient = serverClient; + + this.files = Collections.emptyList(); + this.filteredFiles = Collections.emptyList(); + + BusProvider.getBus().register(this); + } + + protected View newView(ViewGroup container) { + View fileView = layoutInflater.inflate(R.layout.view_server_file_metadata_item, container, false); + + fileView.setTag(Tags.FILE_TITLE, fileView.findViewById(R.id.text)); + fileView.setTag(Tags.FILE_ICON, fileView.findViewById(R.id.icon)); + + return fileView; + } + + protected void bindView(ServerFile file, View fileView) { + unbindFileView(file, fileView); + + ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); + if (Mimes.match(file.getMime()) != Mimes.Type.VIDEO) { + bindFileView(file, fileView); + } else { + if (!file.isMetaDataFetched()) { + bindFileMetadataView(file, fileView); + } else { + bindView(file, file.getFileMetadata(), fileView); + } + } + if (Mimes.match(file.getMime()) == Mimes.Type.IMAGE) { + setUpImageIcon(file, fileIcon); + } + } + + private void unbindFileView(ServerFile file, View fileView) { + TextView fileTitle = (TextView) fileView.getTag(Tags.FILE_TITLE); + ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); + + fileTitle.setText(null); + fileTitle.setBackgroundResource(android.R.color.transparent); + + fileIcon.setImageResource(Mimes.getFileIcon(file)); + fileIcon.setBackgroundResource(R.color.background_secondary); + } + + private void bindFileView(ServerFile file, View fileView) { + TextView fileTitle = (TextView) fileView.getTag(Tags.FILE_TITLE); + ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); + + SpannableStringBuilder sb = new SpannableStringBuilder(file.getName()); + if (queryString != null && !TextUtils.isEmpty(queryString)) { + int searchMatchPosition = file.getName().toLowerCase().indexOf(queryString.toLowerCase()); + if (searchMatchPosition != -1) + sb.setSpan(fcs, searchMatchPosition, searchMatchPosition + queryString.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + fileTitle.setText(sb); + fileTitle.setBackgroundResource(R.color.background_transparent_secondary); + + fileIcon.setImageResource(Mimes.getFileIcon(file)); + fileIcon.setBackgroundResource(R.color.background_secondary); + } + + private void bindFileMetadataView(ServerFile file, View fileView) { + fileView.setTag(Tags.SHARE, serverShare); + fileView.setTag(Tags.FILE, file); + + new FileMetadataRetrievingTask(serverClient, fileView).execute(); + } + + @Subscribe + public void onFileMetadataRetrieved(FileMetadataRetrievedEvent event) { + event.getFile().setMetaDataFetched(true); + bindView(event.getFile(), event.getFileMetadata(), event.getFileView()); + } + + private void bindView(ServerFile file, ServerFileMetadata fileMetadata, View fileView) { + if (fileMetadata == null) { + bindFileView(file, fileView); + } else { + file.setFileMetadata(fileMetadata); + bindFileMetadataView(file, fileMetadata, fileView); + } + } + + private void bindFileMetadataView(ServerFile file, ServerFileMetadata fileMetadata, View fileView) { + TextView fileTitle = (TextView) fileView.getTag(Tags.FILE_TITLE); + ImageView fileIcon = (ImageView) fileView.getTag(Tags.FILE_ICON); + + fileTitle.setText(null); + fileTitle.setBackgroundResource(android.R.color.transparent); + + Glide.with(fileView.getContext()) + .load(fileMetadata.getArtworkUrl()) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .centerCrop() + .fitCenter() + .placeholder(Mimes.getFileIcon(file)) + .error(Mimes.getFileIcon(file)) + .into(fileIcon); + } + + public void tearDownCallbacks() { + BusProvider.getBus().unregister(this); + } + + public static final class Tags { + public static final int SHARE = R.id.container_files; + public static final int FILE = R.attr.server_share; + public static final int FILE_TITLE = R.id.text; + public static final int FILE_ICON = R.id.icon; + private Tags() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/adapter/ServerSharesAdapter.java b/src/main/java/org/amahi/anywhere/adapter/ServerSharesAdapter.java index f0ed08feb..b16dbc737 100644 --- a/src/main/java/org/amahi/anywhere/adapter/ServerSharesAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/ServerSharesAdapter.java @@ -38,59 +38,59 @@ * Shares adapter. Visualizes shares * for the {@link org.amahi.anywhere.fragment.ServerSharesFragment}. */ -public class ServerSharesAdapter extends RecyclerView.Adapter -{ - private List shares; - - class ServerShareViewHolder extends RecyclerView.ViewHolder{ - TextView textView; - ServerShareViewHolder(View itemView) { - super(itemView); - textView = (TextView)itemView.findViewById(R.id.text); - } - } - - @Override - public ServerShareViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - return new ServerShareViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.view_server_share_item, parent, false)); - } - - @Override - public void onBindViewHolder(final ServerShareViewHolder holder, int position) { - holder.textView.setText(shares.get(position).getName()); - holder.itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - BusProvider.getBus().post(new ShareSelectedEvent(shares.get(holder.getAdapterPosition()))); - } - }); - } - - @Override - public int getItemCount() { - return shares.size(); - } - - public ServerSharesAdapter(Context context) { - this.shares = Collections.emptyList(); - } - - public void replaceWith(List shares) { - this.shares = shares; - - notifyDataSetChanged(); - } - - public List getItems() { - return shares; - } - - public ServerShare getItem(int position) { - return shares.get(position); - } - - @Override - public long getItemId(int position) { - return position; - } +public class ServerSharesAdapter extends RecyclerView.Adapter { + private List shares; + + public ServerSharesAdapter(Context context) { + this.shares = Collections.emptyList(); + } + + @Override + public ServerShareViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new ServerShareViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.view_server_share_item, parent, false)); + } + + @Override + public void onBindViewHolder(final ServerShareViewHolder holder, int position) { + holder.textView.setText(shares.get(position).getName()); + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + BusProvider.getBus().post(new ShareSelectedEvent(shares.get(holder.getAdapterPosition()))); + } + }); + } + + @Override + public int getItemCount() { + return shares.size(); + } + + public void replaceWith(List shares) { + this.shares = shares; + + notifyDataSetChanged(); + } + + public List getItems() { + return shares; + } + + public ServerShare getItem(int position) { + return shares.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + class ServerShareViewHolder extends RecyclerView.ViewHolder { + TextView textView; + + ServerShareViewHolder(View itemView) { + super(itemView); + textView = (TextView) itemView.findViewById(R.id.text); + } + } } diff --git a/src/main/java/org/amahi/anywhere/adapter/ServersAdapter.java b/src/main/java/org/amahi/anywhere/adapter/ServersAdapter.java index 2c64f501c..482bcc5b5 100644 --- a/src/main/java/org/amahi/anywhere/adapter/ServersAdapter.java +++ b/src/main/java/org/amahi/anywhere/adapter/ServersAdapter.java @@ -35,90 +35,89 @@ * Servers adapter. Visualizes servers * for the {@link org.amahi.anywhere.fragment.NavigationFragment}. */ -public class ServersAdapter extends BaseAdapter -{ - private final LayoutInflater layoutInflater; +public class ServersAdapter extends BaseAdapter { + private final LayoutInflater layoutInflater; - private List servers; + private List servers; - public ServersAdapter(Context context) { - this.layoutInflater = LayoutInflater.from(context); + public ServersAdapter(Context context) { + this.layoutInflater = LayoutInflater.from(context); - this.servers = Collections.emptyList(); - } + this.servers = Collections.emptyList(); + } - public void replaceWith(List servers) { - this.servers = servers; + public void replaceWith(List servers) { + this.servers = servers; - notifyDataSetChanged(); - } + notifyDataSetChanged(); + } - @Override - public int getCount() { - return servers.size(); - } + @Override + public int getCount() { + return servers.size(); + } - public List getItems() { - return servers; - } + public List getItems() { + return servers; + } - @Override - public Server getItem(int position) { - return servers.get(position); - } + @Override + public Server getItem(int position) { + return servers.get(position); + } - @Override - public long getItemId(int position) { - return position; - } + @Override + public long getItemId(int position) { + return position; + } - @Override - public View getView(int position, View view, ViewGroup container) { - Server server = getItem(position); + @Override + public View getView(int position, View view, ViewGroup container) { + Server server = getItem(position); - if (view == null) { - view = newView(container); - } + if (view == null) { + view = newView(container); + } - bindView(server, view); + bindView(server, view); - return view; - } + return view; + } - private View newView(ViewGroup container) { - return layoutInflater.inflate(android.R.layout.simple_spinner_item, container, false); - } + private View newView(ViewGroup container) { + return layoutInflater.inflate(android.R.layout.simple_spinner_item, container, false); + } - private void bindView(Server server, View view) { - TextView serverView = (TextView) view; + private void bindView(Server server, View view) { + TextView serverView = (TextView) view; - serverView.setText(getServerName(server)); - } + serverView.setText(getServerName(server)); + } - private String getServerName(Server server) { - return server.getName(); - } + private String getServerName(Server server) { + return server.getName(); + } - @Override - public View getDropDownView(int position, View view, ViewGroup container) { - Server server = getItem(position); + @Override + public View getDropDownView(int position, View view, ViewGroup container) { + Server server = getItem(position); - if (view == null) { - view = newDropDownView(container); - } + if (view == null) { + view = newDropDownView(container); + } - bindDropDownView(server, view); + bindDropDownView(server, view); - return view; - } + return view; + } - private View newDropDownView(ViewGroup container) { - return layoutInflater.inflate(android.R.layout.simple_spinner_dropdown_item, container, false); - } + private View newDropDownView(ViewGroup container) { + return layoutInflater.inflate(android.R.layout.simple_spinner_dropdown_item, container, false); + } - private void bindDropDownView(Server server, View view) { - TextView serverView = (TextView) view; + private void bindDropDownView(Server server, View view) { + TextView serverView = (TextView) view; - serverView.setText(getServerName(server)); - } + serverView.setText(getServerName(server)); + } } diff --git a/src/main/java/org/amahi/anywhere/adapter/UploadOptionsAdapter.java b/src/main/java/org/amahi/anywhere/adapter/UploadOptionsAdapter.java new file mode 100644 index 000000000..58058a947 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/adapter/UploadOptionsAdapter.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.fragment.UploadBottomSheet; +import org.amahi.anywhere.model.UploadOption; + +import java.util.ArrayList; + +/** + * Upload options adapter. + * for the {@link UploadBottomSheet}. + */ +public class UploadOptionsAdapter extends BaseAdapter { + private ArrayList uploadOptions; + private LayoutInflater inflater; + + public UploadOptionsAdapter(Context context, ArrayList uploadOptions) { + this.uploadOptions = uploadOptions; + this.inflater = LayoutInflater.from(context); + } + + @Override + public int getCount() { + return uploadOptions.size(); + } + + @Override + public Object getItem(int position) { + return uploadOptions.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ViewHolder holder; + UploadOption uploadOption = uploadOptions.get(position); + + if (convertView == null) { + holder = new ViewHolder(); + convertView = inflater.inflate(R.layout.upload_list_item, parent, false); + convertView.setTag(holder); + + holder.image = (ImageView) convertView.findViewById(R.id.option_icon); + holder.text = (TextView) convertView.findViewById(R.id.option_text); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + holder.image.setImageResource(uploadOption.getIcon()); + holder.text.setText(uploadOption.getName()); + + return convertView; + } + + static class ViewHolder { + ImageView image; + TextView text; + } +} diff --git a/src/main/java/org/amahi/anywhere/bus/AudioMetadataRetrievedEvent.java b/src/main/java/org/amahi/anywhere/bus/AudioMetadataRetrievedEvent.java index 7c263e8bc..0d7f231a5 100644 --- a/src/main/java/org/amahi/anywhere/bus/AudioMetadataRetrievedEvent.java +++ b/src/main/java/org/amahi/anywhere/bus/AudioMetadataRetrievedEvent.java @@ -21,17 +21,37 @@ import android.graphics.Bitmap; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; + public class AudioMetadataRetrievedEvent implements BusEvent { private final String audioTitle; private final String audioArtist; private final String audioAlbum; + private final long duration; private final Bitmap audioAlbumArt; + private final MainTVPresenter.ViewHolder viewHolder; + private final ServerFile serverFile; - public AudioMetadataRetrievedEvent(String audioTitle, String audioArtist, String audioAlbum, Bitmap audioAlbumArt) { + public AudioMetadataRetrievedEvent(String audioTitle, String audioArtist, String audioAlbum, + String duration, Bitmap audioAlbumArt, + MainTVPresenter.ViewHolder viewHolder, + ServerFile serverFile) { this.audioTitle = audioTitle; this.audioArtist = audioArtist; this.audioAlbum = audioAlbum; + if (duration != null) { + this.duration = Long.valueOf(duration); + } else { + this.duration = 0; + } this.audioAlbumArt = audioAlbumArt; + this.viewHolder = viewHolder; + this.serverFile = serverFile; + } + + public AudioMetadataRetrievedEvent(MainTVPresenter.ViewHolder viewHolder, ServerFile serverFile) { + this(null, null, null, null, null, viewHolder, serverFile); } public String getAudioTitle() { @@ -49,4 +69,16 @@ public String getAudioAlbum() { public Bitmap getAudioAlbumArt() { return audioAlbumArt; } + + public MainTVPresenter.ViewHolder getViewHolder() { + return viewHolder; + } + + public ServerFile getServerFile() { + return serverFile; + } + + public long getDuration() { + return duration; + } } diff --git a/src/main/java/org/amahi/anywhere/bus/FileMetadataRetrievedEvent.java b/src/main/java/org/amahi/anywhere/bus/FileMetadataRetrievedEvent.java index e3b5952b4..01afcfb0f 100644 --- a/src/main/java/org/amahi/anywhere/bus/FileMetadataRetrievedEvent.java +++ b/src/main/java/org/amahi/anywhere/bus/FileMetadataRetrievedEvent.java @@ -23,16 +23,19 @@ import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerFileMetadata; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; public class FileMetadataRetrievedEvent implements BusEvent { private final ServerFile file; private final ServerFileMetadata fileMetadata; private final View fileView; + private MainTVPresenter.ViewHolder viewHolder; - public FileMetadataRetrievedEvent(ServerFile file, ServerFileMetadata fileMetadata, View fileView) { + public FileMetadataRetrievedEvent(ServerFile file, ServerFileMetadata fileMetadata, View fileView, MainTVPresenter.ViewHolder viewHolder) { this.file = file; this.fileMetadata = fileMetadata; this.fileView = fileView; + this.viewHolder = viewHolder; } public ServerFile getFile() { @@ -46,4 +49,8 @@ public ServerFileMetadata getFileMetadata() { public View getFileView() { return fileView; } + + public MainTVPresenter.ViewHolder getViewHolder() { + return viewHolder; + } } diff --git a/src/main/java/org/amahi/anywhere/bus/ServerConnectedEvent.java b/src/main/java/org/amahi/anywhere/bus/ServerConnectedEvent.java index a757c0412..aa7c6f78f 100644 --- a/src/main/java/org/amahi/anywhere/bus/ServerConnectedEvent.java +++ b/src/main/java/org/amahi/anywhere/bus/ServerConnectedEvent.java @@ -19,5 +19,16 @@ package org.amahi.anywhere.bus; +import org.amahi.anywhere.server.model.Server; + public class ServerConnectedEvent implements BusEvent { + public Server server; + + public ServerConnectedEvent(Server server) { + this.server = server; + } + + public Server getServer() { + return server; + } } diff --git a/src/main/java/org/amahi/anywhere/bus/ServerFileDeleteEvent.java b/src/main/java/org/amahi/anywhere/bus/ServerFileDeleteEvent.java new file mode 100644 index 000000000..f90ca16f5 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/bus/ServerFileDeleteEvent.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.bus; + +public class ServerFileDeleteEvent implements BusEvent { + private boolean isDeleted; + + public ServerFileDeleteEvent(boolean isDeleted) { + this.isDeleted = isDeleted; + } + + public boolean isDeleted() { + return isDeleted; + } +} diff --git a/src/main/java/org/amahi/anywhere/bus/ServerFileUploadCompleteEvent.java b/src/main/java/org/amahi/anywhere/bus/ServerFileUploadCompleteEvent.java new file mode 100644 index 000000000..a0a0f1751 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/bus/ServerFileUploadCompleteEvent.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.bus; + +public class ServerFileUploadCompleteEvent implements BusEvent { + private int id; + private boolean wasUploadSuccessful; + + public ServerFileUploadCompleteEvent(int id, boolean wasUploadSuccessful) { + this.id = id; + this.wasUploadSuccessful = wasUploadSuccessful; + } + + public int getId() { + return id; + } + + public boolean wasUploadSuccessful() { + return wasUploadSuccessful; + } +} diff --git a/src/main/java/org/amahi/anywhere/bus/ServerFileUploadProgressEvent.java b/src/main/java/org/amahi/anywhere/bus/ServerFileUploadProgressEvent.java new file mode 100644 index 000000000..16f8896bc --- /dev/null +++ b/src/main/java/org/amahi/anywhere/bus/ServerFileUploadProgressEvent.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.bus; + +public class ServerFileUploadProgressEvent implements BusEvent { + private int id; + private int progress; + + public ServerFileUploadProgressEvent(int id, int progress) { + this.id = id; + this.progress = progress; + } + + public int getId() { + return this.id; + } + + public int getProgress() { + return this.progress; + } +} diff --git a/src/main/java/org/amahi/anywhere/bus/UploadClickEvent.java b/src/main/java/org/amahi/anywhere/bus/UploadClickEvent.java new file mode 100644 index 000000000..e3e242a09 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/bus/UploadClickEvent.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.bus; + +import org.amahi.anywhere.model.UploadOption; + +public class UploadClickEvent implements BusEvent { + private int uploadOption; + + public UploadClickEvent(@UploadOption.Types int uploadOption) { + this.uploadOption = uploadOption; + } + + @UploadOption.Types + public int getUploadOption() { + return uploadOption; + } +} diff --git a/src/main/java/org/amahi/anywhere/bus/UploadSettingsOpeningEvent.java b/src/main/java/org/amahi/anywhere/bus/UploadSettingsOpeningEvent.java new file mode 100644 index 000000000..43ebb29a9 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/bus/UploadSettingsOpeningEvent.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.bus; + +public class UploadSettingsOpeningEvent implements BusEvent { +} diff --git a/src/main/java/org/amahi/anywhere/db/UploadQueueDb.java b/src/main/java/org/amahi/anywhere/db/UploadQueueDb.java new file mode 100644 index 000000000..679762c39 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/db/UploadQueueDb.java @@ -0,0 +1,41 @@ +package org.amahi.anywhere.db; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +/** + * SQLite db for maintaining image uploads in a persistent database. + * Query methods managed by {@link UploadQueueDbHelper UploadQueueDbHelper}. + */ + +class UploadQueueDb extends SQLiteOpenHelper { + + // Table name + static final String TABLE_NAME = "UPLOAD_QUEUE_TABLE"; + // column names + static final String KEY_ID = "id"; + static final String KEY_FILE_PATH = "file_path"; + // Database version + private static final int DATABASE_VERSION = 1; + // Database Name + private static final String DATABASE_NAME = "AMAHI_ANYWHERE_DATABASE"; + + UploadQueueDb(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + + KEY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + KEY_FILE_PATH + " VARCHAR(200) NOT NULL)"; + + db.execSQL(CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + } + +} diff --git a/src/main/java/org/amahi/anywhere/db/UploadQueueDbHelper.java b/src/main/java/org/amahi/anywhere/db/UploadQueueDbHelper.java new file mode 100644 index 000000000..1d521f602 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/db/UploadQueueDbHelper.java @@ -0,0 +1,94 @@ +package org.amahi.anywhere.db; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.amahi.anywhere.model.UploadFile; + +import java.util.ArrayList; + +import static org.amahi.anywhere.db.UploadQueueDb.KEY_ID; +import static org.amahi.anywhere.db.UploadQueueDb.TABLE_NAME; + +/** + * Performs CRUD operation on SQLite db provided by {@link UploadQueueDb UploadQueueDb}. + */ + +public class UploadQueueDbHelper { + + private static UploadQueueDbHelper uploadQueueDbHelper; + private UploadQueueDb uploadQueueDb; + private SQLiteDatabase sqLiteDatabase; + + private UploadQueueDbHelper(Context context) { + uploadQueueDb = new UploadQueueDb(context); + sqLiteDatabase = uploadQueueDb.getWritableDatabase(); + } + + public static UploadQueueDbHelper init(Context context) { + if (uploadQueueDbHelper == null) uploadQueueDbHelper = new UploadQueueDbHelper(context); + return uploadQueueDbHelper; + } + + public UploadFile addNewImagePath(String imagePath) { + ContentValues values = new ContentValues(); + + values.put(UploadQueueDb.KEY_FILE_PATH, imagePath); + int id = (int) sqLiteDatabase.insert(TABLE_NAME, null, values); + if (id != -1) { + return new UploadFile(id, imagePath); + } else { + return null; + } + } + + public ArrayList getAllImagePaths() { + ArrayList imagePaths = new ArrayList<>(); + + Cursor cursor = sqLiteDatabase.query(TABLE_NAME, null, null, null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + while (!cursor.isAfterLast()) { + int id = cursor.getInt( + cursor.getColumnIndex(UploadQueueDb.KEY_ID)); + String imagePath = cursor.getString( + cursor.getColumnIndex(UploadQueueDb.KEY_FILE_PATH)); + + UploadFile uploadFile = new UploadFile(id, imagePath); + imagePaths.add(uploadFile); + cursor.moveToNext(); + } + } + if (cursor != null) { + cursor.close(); + } + + return imagePaths; + } + + public void removeFirstImagePath() { + Cursor cursor = sqLiteDatabase.query(TABLE_NAME, null, null, null, null, null, null); + if (cursor.moveToFirst()) { + String rowId = cursor.getString(cursor.getColumnIndex(KEY_ID)); + + sqLiteDatabase.delete(TABLE_NAME, KEY_ID + "=?", new String[]{rowId}); + } + cursor.close(); + } + + public void removeImagePath(int id) { + sqLiteDatabase.delete(TABLE_NAME, KEY_ID + "=?", new String[]{String.valueOf(id)}); + } + + public void clearDb() { + sqLiteDatabase.execSQL("DELETE FROM " + TABLE_NAME); + } + + public void closeDataBase() { + sqLiteDatabase.close(); + uploadQueueDb.close(); + uploadQueueDbHelper = null; + } + +} diff --git a/src/main/java/org/amahi/anywhere/fragment/GooglePlaySearchFragment.java b/src/main/java/org/amahi/anywhere/fragment/GooglePlaySearchFragment.java index ddeac2aa0..5ed81aa87 100644 --- a/src/main/java/org/amahi/anywhere/fragment/GooglePlaySearchFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/GooglePlaySearchFragment.java @@ -19,13 +19,13 @@ package org.amahi.anywhere.fragment; -import android.support.design.widget.Snackbar; -import android.support.v7.app.AlertDialog; import android.app.Dialog; import android.app.DialogFragment; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; +import android.support.design.widget.Snackbar; +import android.support.v7.app.AlertDialog; import org.amahi.anywhere.R; import org.amahi.anywhere.server.model.ServerFile; @@ -35,60 +35,58 @@ /** * Application search dialog. */ -public class GooglePlaySearchFragment extends DialogFragment implements DialogInterface.OnClickListener -{ - public static final String TAG = "google_play_search"; +public class GooglePlaySearchFragment extends DialogFragment implements DialogInterface.OnClickListener { + public static final String TAG = "google_play_search"; - public static GooglePlaySearchFragment newInstance(ServerFile file) { - GooglePlaySearchFragment fragment = new GooglePlaySearchFragment(); + public static GooglePlaySearchFragment newInstance(ServerFile file) { + GooglePlaySearchFragment fragment = new GooglePlaySearchFragment(); - fragment.setArguments(buildArguments(file)); + fragment.setArguments(buildArguments(file)); - return fragment; - } + return fragment; + } - private static Bundle buildArguments(ServerFile file) { - Bundle arguments = new Bundle(); + private static Bundle buildArguments(ServerFile file) { + Bundle arguments = new Bundle(); - arguments.putParcelable(Fragments.Arguments.SERVER_FILE, file); + arguments.putParcelable(Fragments.Arguments.SERVER_FILE, file); - return arguments; - } + return arguments; + } - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - return buildDialog(); - } + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return buildDialog(); + } - private Dialog buildDialog() { - AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity()); + private Dialog buildDialog() { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity()); - dialogBuilder.setMessage(getString(R.string.message_error_search_application)); - dialogBuilder.setPositiveButton(getString(R.string.button_search_application), this); + dialogBuilder.setMessage(getString(R.string.message_error_search_application)); + dialogBuilder.setPositiveButton(getString(R.string.button_search_application), this); - return dialogBuilder.create(); - } + return dialogBuilder.create(); + } - @Override - public void onClick(DialogInterface dialog, int id) { - this.dismiss(); + @Override + public void onClick(DialogInterface dialog, int id) { + this.dismiss(); - startGooglePlaySearch(); - } + startGooglePlaySearch(); + } - private void startGooglePlaySearch() { - String search = getFile().getMime(); + private void startGooglePlaySearch() { + String search = getFile().getMime(); - Intent intent = Intents.Builder.with(getActivity()).buildGooglePlaySearchIntent(search); - if (intent.resolveActivity(getActivity().getPackageManager()) != null) { - startActivity(intent); - } - else { - Snackbar.make(getView(),getString(R.string.application_not_found),Snackbar.LENGTH_SHORT).show(); - } - } + Intent intent = Intents.Builder.with(getActivity()).buildGooglePlaySearchIntent(search); + if (intent.resolveActivity(getActivity().getPackageManager()) != null) { + startActivity(intent); + } else { + Snackbar.make(getView(), getString(R.string.application_not_found), Snackbar.LENGTH_SHORT).show(); + } + } - private ServerFile getFile() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); - } + private ServerFile getFile() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/NavigationFragment.java b/src/main/java/org/amahi/anywhere/fragment/NavigationFragment.java index 3d05aa81c..31d23eb70 100644 --- a/src/main/java/org/amahi/anywhere/fragment/NavigationFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/NavigationFragment.java @@ -26,11 +26,12 @@ import android.accounts.AuthenticatorException; import android.accounts.OnAccountsUpdateListener; import android.accounts.OperationCanceledException; -import android.support.v4.app.Fragment; +import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.os.Parcelable; import android.preference.PreferenceManager; +import android.support.v4.app.Fragment; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -48,6 +49,7 @@ import org.amahi.anywhere.AmahiApplication; import org.amahi.anywhere.R; import org.amahi.anywhere.account.AmahiAccount; +import org.amahi.anywhere.activity.IntroductionActivity; import org.amahi.anywhere.adapter.NavigationDrawerAdapter; import org.amahi.anywhere.adapter.ServersAdapter; import org.amahi.anywhere.bus.AppsSelectedEvent; @@ -61,6 +63,9 @@ import org.amahi.anywhere.server.client.AmahiClient; import org.amahi.anywhere.server.client.ServerClient; import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.tv.activity.MainTVActivity; +import org.amahi.anywhere.util.CheckTV; +import org.amahi.anywhere.util.Preferences; import org.amahi.anywhere.util.RecyclerItemClickListener; import org.amahi.anywhere.util.ViewDirector; @@ -75,405 +80,451 @@ * Navigation fragments. Shows main application sections and servers list as well. */ public class NavigationFragment extends Fragment implements AccountManagerCallback, - OnAccountsUpdateListener, - AdapterView.OnItemSelectedListener, - SwipeRefreshLayout.OnRefreshListener -{ - private static final class State - { - private State() { - } + OnAccountsUpdateListener, + AdapterView.OnItemSelectedListener, + SwipeRefreshLayout.OnRefreshListener { + @Inject + AmahiClient amahiClient; + @Inject + ServerClient serverClient; + private Intent tvIntent; + + @Override + public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { + return layoutInflater.inflate(R.layout.fragment_navigation, container, false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + launchIntro(); + + setUpInjections(); + + setUpSettingsMenu(); + + setUpAuthenticationListener(); + + setUpContentRefreshing(); + + setUpServers(savedInstanceState); + } + + private void launchIntro() { + if (Preferences.getFirstRun(getContext()) && !CheckTV.isATV(getContext())) { + Preferences.setFirstRun(getContext()); + startActivity(new Intent(getContext(), IntroductionActivity.class)); + } + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setUpSettingsMenu() { + setHasOptionsMenu(true); + } + + private void setUpAuthenticationListener() { + getAccountManager().addOnAccountsUpdatedListener(this, null, false); + } + + private AccountManager getAccountManager() { + return AccountManager.get(getActivity()); + } + + @Override + public void onAccountsUpdated(Account[] accounts) { + if (isVisible()) { + return; + } + + if (getAccounts().isEmpty()) { + setUpAccount(); + } + } + + private void setUpContentRefreshing() { + SwipeRefreshLayout refreshLayout = getRefreshLayout(); + + refreshLayout.setColorSchemeResources( + android.R.color.holo_blue_light, + android.R.color.holo_orange_light, + android.R.color.holo_green_light, + android.R.color.holo_red_light); + + refreshLayout.setOnRefreshListener(this); + } + + @Override + public void onRefresh() { + ViewDirector.of(this, R.id.animator_content).show(R.id.empty_view); + setUpServers(new Bundle()); + } + + private List getAccounts() { + return Arrays.asList(getAccountManager().getAccountsByType(AmahiAccount.TYPE)); + } + + private void setUpAccount() { + getAccountManager().addAccount(AmahiAccount.TYPE, AmahiAccount.TYPE_TOKEN, null, null, getActivity(), this, null); + } + + private void setUpAuthenticationToken() { + Account account = getAccounts().get(0); + + getAccountManager().getAuthToken(account, AmahiAccount.TYPE, null, getActivity(), this, null); + } + + @Override + public void run(AccountManagerFuture accountManagerFuture) { + try { + Bundle accountManagerResult = accountManagerFuture.getResult(); + + String authenticationToken = accountManagerResult.getString(AccountManager.KEY_AUTHTOKEN); + + if (authenticationToken != null) { + setUpServers(authenticationToken); + } else { + setUpAuthenticationToken(); + } + } catch (OperationCanceledException e) { + tearDownActivity(); + } catch (IOException | AuthenticatorException e) { + throw new RuntimeException(e); + } + } + + private void tearDownActivity() { + getActivity().finish(); + } + + private void setUpServers(Bundle state) { + getRefreshLayout().setRefreshing(true); + setUpServersAdapter(); + setUpServersContent(state); + setUpServersListener(); + } + + private void setUpServersAdapter() { + if (!areServersLoaded()) + getServersSpinner().setAdapter(new ServersAdapter(getActivity())); + } + + private Spinner getServersSpinner() { + return (Spinner) getView().findViewById(R.id.spinner_servers); + } + + private void setUpServersContent(Bundle state) { + if (isServersStateValid(state)) { + setUpServersState(state); + setUpNavigation(); + } else { + setUpAuthentication(); + } + } + + private boolean isServersStateValid(Bundle state) { + return (state != null) && state.containsKey(State.SERVERS); + } + + private void setUpServersState(Bundle state) { + List servers = state.getParcelableArrayList(State.SERVERS); + + setUpServersContent(servers); + + showContent(); + } + + private void setUpServersContent(List servers) { + if (!CheckTV.isATV(getContext())) + getServersAdapter().replaceWith(filterActiveServers(servers)); + else { + List serverList = filterActiveServers(servers); + String serverName = Preferences.getPreference(getContext()).getString(getString(R.string.pref_server_select_key), servers.get(0).getName()); + + if (serverList.get(0).getName().matches(serverName)) + getServersAdapter().replaceWith(serverList); + + else { + int index = findTheServer(serverList); + getServersAdapter().replaceWith(swappedServers(index, serverList)); + } + } + } + + private int findTheServer(List serverList) { + String serverName = Preferences.getPreference(getContext()).getString(getString(R.string.pref_server_select_key), serverList.get(0).getName()); + int i; + for (i = 0; i < serverList.size(); i++) { + if (serverName.matches(serverList.get(i).getName())) + return i; + } + return 0; + } + + private List swappedServers(int index, List serverList) { + Server firstServer = serverList.get(0); + serverList.set(0, serverList.get(index)); + serverList.set(index, firstServer); + return serverList; + } + + private ServersAdapter getServersAdapter() { + return (ServersAdapter) getServersSpinner().getAdapter(); + } + + private List filterActiveServers(List servers) { + List activeServers = new ArrayList(); + + for (Server server : servers) { + if (server.isActive()) { + activeServers.add(server); + } + } + + return activeServers; + } + + private void showContent() { + getRefreshLayout().setRefreshing(false); + ViewDirector.of(this, R.id.animator_content).show(R.id.layout_content); + } + + private void setUpAuthentication() { + if (getAccounts().isEmpty()) { + setUpAccount(); + } else { + setUpAuthenticationToken(); + } + } + + private void setUpServers(String authenticationToken) { + setUpServersAdapter(); + setUpServersContent(authenticationToken); + setUpServersListener(); + } + + private void setUpServersContent(String authenticationToken) { + amahiClient.getServers(getContext(), authenticationToken); + } + + @Subscribe + public void onServersLoaded(ServersLoadedEvent event) { + setUpServersContent(event.getServers()); + + setUpNavigation(); + + showContent(); + + tvIntent = new Intent(getContext(), MainTVActivity.class); + + tvIntent.putParcelableArrayListExtra(getString(R.string.intent_servers), new ArrayList<>(filterActiveServers(event.getServers()))); + } + + private SwipeRefreshLayout getRefreshLayout() { + return (SwipeRefreshLayout) getView().findViewById(R.id.layout_refresh); + } + + private void setUpNavigation() { + setUpNavigationAdapter(); + setUpNavigationListener(); + } + + private void setUpNavigationAdapter() { + //Setting the layout of a vertical list dynamically. + getNavigationListView().setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false)); + + if (!serverClient.isConnected()) { + getNavigationListView().setAdapter(NavigationDrawerAdapter.newRemoteAdapter(getActivity())); + return; + } + + if (serverClient.isConnectedLocal()) { + getNavigationListView().setAdapter(NavigationDrawerAdapter.newLocalAdapter(getActivity())); + } else { + getNavigationListView().setAdapter(NavigationDrawerAdapter.newRemoteAdapter(getActivity())); + } + } + + private RecyclerView getNavigationListView() { + return (RecyclerView) getView().findViewById(R.id.list_navigation); + } + + private void setUpNavigationListener() { + getNavigationListView().addOnItemTouchListener(new RecyclerItemClickListener(getContext(), new RecyclerItemClickListener.OnItemClickListener() { + @Override + public void onItemClick(View view, int position) { + view.setActivated(true); + switch (position) { + case NavigationDrawerAdapter.NavigationItems.SHARES: + BusProvider.getBus().post(new SharesSelectedEvent()); + break; + + case NavigationDrawerAdapter.NavigationItems.APPS: + BusProvider.getBus().post(new AppsSelectedEvent()); + break; + + default: + break; + } + } + })); + } + + @Subscribe + public void onServersLoadFailed(ServersLoadFailedEvent event) { + showError(); + } + + private void showError() { + getRefreshLayout().setRefreshing(false); + ViewDirector.of(this, R.id.animator_content).show(R.id.layout_error); + } + + private void setUpServersListener() { + getServersSpinner().setOnItemSelectedListener(this); + } + + @Override + public void onNothingSelected(AdapterView spinnerView) { + } + + @Override + public void onItemSelected(AdapterView spinnerView, View view, int position, long id) { + Server server = getServersAdapter().getItem(position); + + setUpServerConnection(server); + } + + private void setUpServerConnection(Server server) { + if (serverClient.isConnected(server)) { + setUpServerConnection(); + setUpServerNavigation(); + } else { + serverClient.connect(getContext(), server); + } + } + + @Subscribe + public void onServerConnected(ServerConnectedEvent event) { + setUpServerConnection(); + setUpServerNavigation(); + if (CheckTV.isATV(getContext())) launchTV(); + } + + private void setUpServerConnection() { + if (!isConnectionAvailable() || isConnectionAuto()) { + serverClient.connectAuto(); + return; + } + + if (isConnectionLocal()) { + serverClient.connectLocal(); + } else { + serverClient.connectRemote(); + } + } + + private void launchTV() { + startActivity(tvIntent); + } + + private boolean isConnectionAvailable() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + + return preferences.contains(getString(R.string.preference_key_server_connection)); + } + + private boolean isConnectionAuto() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); + + return preferenceConnection.equals(getString(R.string.preference_key_server_connection_auto)); + } + + private boolean isConnectionLocal() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); + + return preferenceConnection.equals(getString(R.string.preference_key_server_connection_local)); + } + + private void setUpServerNavigation() { + setUpNavigationAdapter(); + } + + @Subscribe + public void onServerConnectionChanged(ServerConnectionChangedEvent event) { + setUpServerNavigation(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + super.onCreateOptionsMenu(menu, menuInflater); + + menuInflater.inflate(R.menu.action_bar_navigation, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.menu_settings: + BusProvider.getBus().post(new SettingsSelectedEvent()); + return true; + + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + public void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + tearDownServersState(outState); + } + + private void tearDownServersState(Bundle state) { + if (areServersLoaded()) { + state.putParcelableArrayList(State.SERVERS, new ArrayList(getServersAdapter().getItems())); + } + } + + private boolean areServersLoaded() { + return getServersAdapter() != null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + tearDownAuthenticationListener(); + } + + private void tearDownAuthenticationListener() { + getAccountManager().removeOnAccountsUpdatedListener(this); + } + + private static final class State { + public static final String SERVERS = "servers"; - public static final String SERVERS = "servers"; - } - - @Inject - AmahiClient amahiClient; - - @Inject - ServerClient serverClient; - - @Override - public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { - return layoutInflater.inflate(R.layout.fragment_navigation, container, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - setUpInjections(); - - setUpSettingsMenu(); - - setUpAuthenticationListener(); - - setUpContentRefreshing(); - - setUpServers(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } - - private void setUpSettingsMenu() { - setHasOptionsMenu(true); - } - - private void setUpAuthenticationListener() { - getAccountManager().addOnAccountsUpdatedListener(this, null, false); - } - - private AccountManager getAccountManager() { - return AccountManager.get(getActivity()); - } - - @Override - public void onAccountsUpdated(Account[] accounts) { - if (isVisible()) { - return; - } - - if (getAccounts().isEmpty()) { - setUpAccount(); - } - } - - private void setUpContentRefreshing() { - SwipeRefreshLayout refreshLayout = getRefreshLayout(); - - refreshLayout.setColorSchemeResources( - android.R.color.holo_blue_light, - android.R.color.holo_orange_light, - android.R.color.holo_green_light, - android.R.color.holo_red_light); - - refreshLayout.setOnRefreshListener(this); - } - - @Override - public void onRefresh() { - ViewDirector.of(this, R.id.animator_content).show(R.id.empty_view); - setUpServers(new Bundle()); - } - - private List getAccounts() { - return Arrays.asList(getAccountManager().getAccountsByType(AmahiAccount.TYPE)); - } - - private void setUpAccount() { - getAccountManager().addAccount(AmahiAccount.TYPE, AmahiAccount.TYPE_TOKEN, null, null, getActivity(), this, null); - } - - private void setUpAuthenticationToken() { - Account account = getAccounts().get(0); - - getAccountManager().getAuthToken(account, AmahiAccount.TYPE, null, getActivity(), this, null); - } - - @Override - public void run(AccountManagerFuture accountManagerFuture) { - try { - Bundle accountManagerResult = accountManagerFuture.getResult(); - - String authenticationToken = accountManagerResult.getString(AccountManager.KEY_AUTHTOKEN); - - if (authenticationToken != null) { - setUpServers(authenticationToken); - } else { - setUpAuthenticationToken(); - } - } catch (OperationCanceledException e) { - tearDownActivity(); - } catch (IOException | AuthenticatorException e) { - throw new RuntimeException(e); - } - } - - private void tearDownActivity() { - getActivity().finish(); - } - - private void setUpServers(Bundle state) { - getRefreshLayout().setRefreshing(true); - setUpServersAdapter(); - setUpServersContent(state); - setUpServersListener(); - } - - private void setUpServersAdapter() { - if (!areServersLoaded()) - getServersSpinner().setAdapter(new ServersAdapter(getActivity())); - } - - private Spinner getServersSpinner() { - return (Spinner) getView().findViewById(R.id.spinner_servers); - } - - private void setUpServersContent(Bundle state) { - if (isServersStateValid(state)) { - setUpServersState(state); - setUpNavigation(); - } else { - setUpAuthentication(); - } - } - - private boolean isServersStateValid(Bundle state) { - return (state != null) && state.containsKey(State.SERVERS); - } - - private void setUpServersState(Bundle state) { - List servers = state.getParcelableArrayList(State.SERVERS); - - setUpServersContent(servers); - - showContent(); - } - - private void setUpServersContent(List servers) { - getServersAdapter().replaceWith(filterActiveServers(servers)); - } - - private ServersAdapter getServersAdapter() { - return (ServersAdapter) getServersSpinner().getAdapter(); - } - - private List filterActiveServers(List servers) { - List activeServers = new ArrayList(); - - for (Server server : servers) { - if (server.isActive()) { - activeServers.add(server); - } - } - - return activeServers; - } - - private void showContent() { - getRefreshLayout().setRefreshing(false); - ViewDirector.of(this, R.id.animator_content).show(R.id.layout_content); - } - - private void setUpAuthentication() { - if (getAccounts().isEmpty()) { - setUpAccount(); - } else { - setUpAuthenticationToken(); - } - } - - private void setUpServers(String authenticationToken) { - setUpServersAdapter(); - setUpServersContent(authenticationToken); - setUpServersListener(); - } - - private void setUpServersContent(String authenticationToken) { - amahiClient.getServers(authenticationToken); - } - - @Subscribe - public void onServersLoaded(ServersLoadedEvent event) { - setUpServersContent(event.getServers()); - - setUpNavigation(); - - showContent(); - } - - private SwipeRefreshLayout getRefreshLayout() { - return (SwipeRefreshLayout) getView().findViewById(R.id.layout_refresh); - } - - private void setUpNavigation() { - setUpNavigationAdapter(); - setUpNavigationListener(); - } - - private void setUpNavigationAdapter() { - //Setting the layout of a vertical list dynamically. - getNavigationListView().setLayoutManager(new LinearLayoutManager(getContext(),LinearLayoutManager.VERTICAL,false)); - - if (!serverClient.isConnected()) { - getNavigationListView().setAdapter(NavigationDrawerAdapter.newRemoteAdapter(getActivity())); - return; - } - - if (serverClient.isConnectedLocal()) { - getNavigationListView().setAdapter(NavigationDrawerAdapter.newLocalAdapter(getActivity())); - } else { - getNavigationListView().setAdapter(NavigationDrawerAdapter.newRemoteAdapter(getActivity())); - } - } - - private RecyclerView getNavigationListView() { - return (RecyclerView) getView().findViewById(R.id.list_navigation); - } - - private void setUpNavigationListener() { - getNavigationListView().addOnItemTouchListener(new RecyclerItemClickListener(getContext(), new RecyclerItemClickListener.OnItemClickListener() { - @Override - public void onItemClick(View view, int position) { - view.setActivated(true); - switch (position) { - case NavigationDrawerAdapter.NavigationItems.SHARES: - BusProvider.getBus().post(new SharesSelectedEvent()); - break; - - case NavigationDrawerAdapter.NavigationItems.APPS: - BusProvider.getBus().post(new AppsSelectedEvent()); - break; - - default: - break; - } - } - })); - } - - @Subscribe - public void onServersLoadFailed(ServersLoadFailedEvent event) { - showError(); - } - - private void showError() { - getRefreshLayout().setRefreshing(false); - ViewDirector.of(this, R.id.animator_content).show(R.id.layout_error); - } - - private void setUpServersListener() { - getServersSpinner().setOnItemSelectedListener(this); - } - - @Override - public void onNothingSelected(AdapterView spinnerView) { - } - - @Override - public void onItemSelected(AdapterView spinnerView, View view, int position, long id) { - Server server = getServersAdapter().getItem(position); - - setUpServerConnection(server); - } - - private void setUpServerConnection(Server server) { - if (serverClient.isConnected(server)) { - setUpServerConnection(); - setUpServerNavigation(); - } else { - serverClient.connect(server); - } - } - - @Subscribe - public void onServerConnected(ServerConnectedEvent event) { - setUpServerConnection(); - setUpServerNavigation(); - } - - private void setUpServerConnection() { - if (!isConnectionAvailable() || isConnectionAuto()) { - serverClient.connectAuto(); - return; - } - - if (isConnectionLocal()) { - serverClient.connectLocal(); - } else { - serverClient.connectRemote(); - } - } - - private boolean isConnectionAvailable() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - - return preferences.contains(getString(R.string.preference_key_server_connection)); - } - - private boolean isConnectionAuto() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); - - return preferenceConnection.equals(getString(R.string.preference_key_server_connection_auto)); - } - - private boolean isConnectionLocal() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); - - return preferenceConnection.equals(getString(R.string.preference_key_server_connection_local)); - } - - private void setUpServerNavigation() { - setUpNavigationAdapter(); - } - - @Subscribe - public void onServerConnectionChanged(ServerConnectionChangedEvent event) { - setUpServerNavigation(); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { - super.onCreateOptionsMenu(menu, menuInflater); - - menuInflater.inflate(R.menu.action_bar_navigation, menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case R.id.menu_settings: - BusProvider.getBus().post(new SettingsSelectedEvent()); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - @Override - public void onResume() { - super.onResume(); - - BusProvider.getBus().register(this); - } - - @Override - public void onPause() { - super.onPause(); - - BusProvider.getBus().unregister(this); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - tearDownServersState(outState); - } - - private void tearDownServersState(Bundle state) { - if (areServersLoaded()) { - state.putParcelableArrayList(State.SERVERS, new ArrayList(getServersAdapter().getItems())); - } - } - - private boolean areServersLoaded() { - return getServersAdapter() != null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - tearDownAuthenticationListener(); - } - - private void tearDownAuthenticationListener() { - getAccountManager().removeOnAccountsUpdatedListener(this); - } + private State() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/ServerAppsFragment.java b/src/main/java/org/amahi/anywhere/fragment/ServerAppsFragment.java index 9edaf86a1..ddf29d8f5 100644 --- a/src/main/java/org/amahi/anywhere/fragment/ServerAppsFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/ServerAppsFragment.java @@ -51,168 +51,164 @@ /** * Apps fragment. Shows apps list. */ -public class ServerAppsFragment extends Fragment -{ - private static final class State - { - private State() { - } +public class ServerAppsFragment extends Fragment { + @Inject + ServerClient serverClient; + private ServerAppsAdapter mServerAppsAdapter; - public static final String APPS = "apps"; - } + private RecyclerView mRecyclerView; - private ServerAppsAdapter mServerAppsAdapter; + private LinearLayout mEmptyLinearLayout; - private RecyclerView mRecyclerView; + private LinearLayout mErrorLinearLayout; - private LinearLayout mEmptyLinearLayout; + @Override + public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = layoutInflater.inflate(R.layout.fragment_server_apps, container, false); - private LinearLayout mErrorLinearLayout; + mRecyclerView = (RecyclerView) rootView.findViewById(R.id.list_server_apps); - @Inject - ServerClient serverClient; + mServerAppsAdapter = new ServerAppsAdapter(getActivity()); - @Override - public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { - View rootView = layoutInflater.inflate(R.layout.fragment_server_apps, container, false); + mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false)); + + mEmptyLinearLayout = (LinearLayout) rootView.findViewById(R.id.empty_server_apps); - mRecyclerView = (RecyclerView)rootView.findViewById(R.id.list_server_apps); + mErrorLinearLayout = (LinearLayout) rootView.findViewById(R.id.error); + + mRecyclerView.addItemDecoration(new + DividerItemDecoration(getActivity(), + DividerItemDecoration.VERTICAL)); + return rootView; + } - mServerAppsAdapter = new ServerAppsAdapter(getActivity()); + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); - mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity(),LinearLayoutManager.VERTICAL,false)); + setUpInjections(); - mEmptyLinearLayout = (LinearLayout)rootView.findViewById(R.id.empty_server_apps); - - mErrorLinearLayout = (LinearLayout) rootView.findViewById(R.id.error); + setUpApps(savedInstanceState); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } - mRecyclerView.addItemDecoration(new - DividerItemDecoration(getActivity(), - DividerItemDecoration.VERTICAL)); - return rootView; - } + private void setUpApps(Bundle state) { + setUpAppsAdapter(); + setUpAppsContent(state); + } + + private void setUpAppsAdapter() { + mRecyclerView.setAdapter(mServerAppsAdapter); + } + + private void setUpAppsContent(Bundle state) { + if (isAppsStateValid(state)) { + setUpAppsState(state); + } else { + setUpAppsContent(); + } + } + + private boolean isAppsStateValid(Bundle state) { + return (state != null) && state.containsKey(State.APPS); + } + + private void setUpAppsState(Bundle state) { + List apps = state.getParcelableArrayList(State.APPS); + if (apps != null) { + mEmptyLinearLayout.setVisibility(View.GONE); + setUpAppsContent(apps); + + showAppsContent(); + } else { + mEmptyLinearLayout.setVisibility(View.VISIBLE); + } + } + + private void setUpAppsContent(List apps) { + getAppsAdapter().replaceWith(apps); + } + + private ServerAppsAdapter getAppsAdapter() { + return mServerAppsAdapter; + } + + private void showAppsContent() { + ViewDirector.of(this, R.id.animator).show(R.id.content); + } + + private void setUpAppsContent() { + if (serverClient.isConnected()) { + serverClient.getApps(); + } + } + + @Subscribe + public void onServerConnectionChanged(ServerConnectionChangedEvent event) { + serverClient.getApps(); + } + + @Subscribe + public void onAppsLoaded(ServerAppsLoadedEvent event) { + setUpAppsContent(event.getServerApps()); - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + showAppsContent(); + } - setUpInjections(); - - setUpApps(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } - - private void setUpApps(Bundle state) { - setUpAppsAdapter(); - setUpAppsContent(state); - } - - private void setUpAppsAdapter() { - mRecyclerView.setAdapter(mServerAppsAdapter); - } - - private void setUpAppsContent(Bundle state) { - if (isAppsStateValid(state)) { - setUpAppsState(state); - } else { - setUpAppsContent(); - } - } - - private boolean isAppsStateValid(Bundle state) { - return (state != null) && state.containsKey(State.APPS); - } - - private void setUpAppsState(Bundle state) { - List apps = state.getParcelableArrayList(State.APPS); - if(apps!=null) { - mEmptyLinearLayout.setVisibility(View.GONE); - setUpAppsContent(apps); - - showAppsContent(); - } - else{ - mEmptyLinearLayout.setVisibility(View.VISIBLE); - } - } - - private void setUpAppsContent(List apps) { - getAppsAdapter().replaceWith(apps); - } - - private ServerAppsAdapter getAppsAdapter() { - return mServerAppsAdapter; - } - - private void showAppsContent() { - ViewDirector.of(this, R.id.animator).show(R.id.content); - } - - private void setUpAppsContent() { - if (serverClient.isConnected()) { - serverClient.getApps(); - } - } - - @Subscribe - public void onServerConnectionChanged(ServerConnectionChangedEvent event) { - serverClient.getApps(); - } - - @Subscribe - public void onAppsLoaded(ServerAppsLoadedEvent event) { - setUpAppsContent(event.getServerApps()); - - showAppsContent(); - } - - @Subscribe - public void onAppsLoadFailed(ServerAppsLoadFailedEvent event) { - showAppsError(); - } - - private void showAppsError() { - ViewDirector.of(this, R.id.animator).show(R.id.error); - mErrorLinearLayout.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - ViewDirector.of(getActivity(), R.id.animator).show(android.R.id.progress); - setUpAppsContent(); - } - }); - } - - @Override - public void onResume() { - super.onResume(); - - BusProvider.getBus().register(this); - } - - @Override - public void onPause() { - super.onPause(); - - BusProvider.getBus().unregister(this); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - tearDownAppsState(outState); - } - - private void tearDownAppsState(Bundle state) { - if (areAppsLoaded()) { - state.putParcelableArrayList(State.APPS, new ArrayList(getAppsAdapter().getItems())); - } - } - - private boolean areAppsLoaded() { - return getAppsAdapter() != null; - } + @Subscribe + public void onAppsLoadFailed(ServerAppsLoadFailedEvent event) { + showAppsError(); + } + + private void showAppsError() { + ViewDirector.of(this, R.id.animator).show(R.id.error); + mErrorLinearLayout.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + ViewDirector.of(getActivity(), R.id.animator).show(android.R.id.progress); + setUpAppsContent(); + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + tearDownAppsState(outState); + } + + private void tearDownAppsState(Bundle state) { + if (areAppsLoaded()) { + state.putParcelableArrayList(State.APPS, new ArrayList(getAppsAdapter().getItems())); + } + } + + private boolean areAppsLoaded() { + return getAppsAdapter() != null; + } + + private static final class State { + public static final String APPS = "apps"; + + private State() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/ServerFileDownloadingFragment.java b/src/main/java/org/amahi/anywhere/fragment/ServerFileDownloadingFragment.java index 8c94dae76..2b646483f 100644 --- a/src/main/java/org/amahi/anywhere/fragment/ServerFileDownloadingFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/ServerFileDownloadingFragment.java @@ -44,99 +44,98 @@ /** * File downloading dialog. */ -public class ServerFileDownloadingFragment extends DialogFragment -{ - public static final String TAG = "server_file_downloading"; +public class ServerFileDownloadingFragment extends DialogFragment { + public static final String TAG = "server_file_downloading"; - @Inject - Downloader downloader; + @Inject + Downloader downloader; - @Inject - ServerClient serverClient; + @Inject + ServerClient serverClient; - public static ServerFileDownloadingFragment newInstance(ServerShare share, ServerFile file) { - ServerFileDownloadingFragment fragment = new ServerFileDownloadingFragment(); + public static ServerFileDownloadingFragment newInstance(ServerShare share, ServerFile file) { + ServerFileDownloadingFragment fragment = new ServerFileDownloadingFragment(); - Bundle arguments = new Bundle(); - arguments.putParcelable(Fragments.Arguments.SERVER_SHARE, share); - arguments.putParcelable(Fragments.Arguments.SERVER_FILE, file); - fragment.setArguments(arguments); + Bundle arguments = new Bundle(); + arguments.putParcelable(Fragments.Arguments.SERVER_SHARE, share); + arguments.putParcelable(Fragments.Arguments.SERVER_FILE, file); + fragment.setArguments(arguments); - return fragment; - } + return fragment; + } - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - ProgressDialog dialog = new ProgressDialog(getActivity()); + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ProgressDialog dialog = new ProgressDialog(getActivity()); - dialog.setMessage(getString(R.string.message_progress_file_downloading)); + dialog.setMessage(getString(R.string.message_progress_file_downloading)); - return dialog; - } + return dialog; + } - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); - setUpInjections(); + setUpInjections(); - startFileDownloading(savedInstanceState); - } + startFileDownloading(savedInstanceState); + } - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } - private void startFileDownloading(Bundle state) { - if (state == null) { - downloader.startFileDownloading(getFileUri(), getFile().getName()); - } - } + private void startFileDownloading(Bundle state) { + if (state == null) { + downloader.startFileDownloading(getFileUri(), getFile().getName()); + } + } - private Uri getFileUri() { - return serverClient.getFileUri(getShare(), getFile()); - } + private Uri getFileUri() { + return serverClient.getFileUri(getShare(), getFile()); + } - private ServerShare getShare() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_SHARE); - } + private ServerShare getShare() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_SHARE); + } - private ServerFile getFile() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); - } + private ServerFile getFile() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); + } - @Subscribe - public void onFileDownloaded(FileDownloadedEvent event) { - dismiss(); - } + @Subscribe + public void onFileDownloaded(FileDownloadedEvent event) { + dismiss(); + } - @Subscribe - public void onFileDownloadFailed(FileDownloadFailedEvent event) { - dismiss(); - } + @Subscribe + public void onFileDownloadFailed(FileDownloadFailedEvent event) { + dismiss(); + } - @Override - public void onResume() { - super.onResume(); + @Override + public void onResume() { + super.onResume(); - BusProvider.getBus().register(this); - } + BusProvider.getBus().register(this); + } - @Override - public void onPause() { - super.onPause(); + @Override + public void onPause() { + super.onPause(); - BusProvider.getBus().unregister(this); - } + BusProvider.getBus().unregister(this); + } - @Override - public void onCancel(DialogInterface dialog) { - super.onCancel(dialog); + @Override + public void onCancel(DialogInterface dialog) { + super.onCancel(dialog); - finishFileDownloading(); - } + finishFileDownloading(); + } - private void finishFileDownloading() { - downloader.finishFileDownloading(); - } + private void finishFileDownloading() { + downloader.finishFileDownloading(); + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/ServerFileImageFragment.java b/src/main/java/org/amahi/anywhere/fragment/ServerFileImageFragment.java index cd2a3b4d4..b2d7d964f 100644 --- a/src/main/java/org/amahi/anywhere/fragment/ServerFileImageFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/ServerFileImageFragment.java @@ -47,84 +47,84 @@ * Image fragment. Shows a single image. */ public class ServerFileImageFragment extends Fragment implements RequestListener { - @Inject - ServerClient serverClient; - - @Override - public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { - return layoutInflater.inflate(R.layout.fragment_server_file_image, container, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - setUpInjections(); - - setUpImage(); - } - - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } - - private void setUpImage() { - setUpImageContent(); - } - - private void setUpImageContent() { - Glide - .with(getActivity()) - .load(getImageUri()) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .listener(this) - .into(getImageView()); - } - - private Uri getImageUri() { - return serverClient.getFileUri(getShare(), getFile()); - } - - private ServerShare getShare() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_SHARE); - } - - private ServerFile getFile() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); - } - - private TouchImageView getImageView() { - return (TouchImageView) getView().findViewById(R.id.image); - } - - private ProgressBar getProgressBar() { - return (ProgressBar) getView().findViewById(android.R.id.progress); - } - - @Override - public boolean onResourceReady(GlideDrawable resource, Uri model, Target target, boolean isFromMemoryCache, boolean isFirstResource) { - showImageContent(); - return false; - } - - private void showImageContent() { - getImageView().setVisibility(View.VISIBLE); - getProgressBar().setVisibility(View.GONE); - } - - @Override - public boolean onException(Exception e, Uri model, Target target, boolean isFirstResource) { - return false; - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - tearDownImageContent(); - } - - private void tearDownImageContent() { - Glide.clear(getImageView()); - } + @Inject + ServerClient serverClient; + + @Override + public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { + return layoutInflater.inflate(R.layout.fragment_server_file_image, container, false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + setUpInjections(); + + setUpImage(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setUpImage() { + setUpImageContent(); + } + + private void setUpImageContent() { + Glide + .with(getActivity()) + .load(getImageUri()) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .listener(this) + .into(getImageView()); + } + + private Uri getImageUri() { + return serverClient.getFileUri(getShare(), getFile()); + } + + private ServerShare getShare() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_SHARE); + } + + private ServerFile getFile() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); + } + + private TouchImageView getImageView() { + return (TouchImageView) getView().findViewById(R.id.image); + } + + private ProgressBar getProgressBar() { + return (ProgressBar) getView().findViewById(android.R.id.progress); + } + + @Override + public boolean onResourceReady(GlideDrawable resource, Uri model, Target target, boolean isFromMemoryCache, boolean isFirstResource) { + showImageContent(); + return false; + } + + private void showImageContent() { + getImageView().setVisibility(View.VISIBLE); + getProgressBar().setVisibility(View.GONE); + } + + @Override + public boolean onException(Exception e, Uri model, Target target, boolean isFirstResource) { + return false; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + tearDownImageContent(); + } + + private void tearDownImageContent() { + Glide.clear(getImageView()); + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/ServerFilesFragment.java b/src/main/java/org/amahi/anywhere/fragment/ServerFilesFragment.java index d17d4ea78..950e57b91 100644 --- a/src/main/java/org/amahi/anywhere/fragment/ServerFilesFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/ServerFilesFragment.java @@ -20,19 +20,20 @@ package org.amahi.anywhere.fragment; import android.Manifest; +import android.app.ProgressDialog; import android.app.SearchManager; import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; +import android.content.DialogInterface; import android.os.Build; import android.os.Bundle; +import android.os.Handler; import android.os.Parcelable; -import android.provider.Settings; +import android.support.annotation.NonNull; import android.support.annotation.RequiresApi; import android.support.design.widget.Snackbar; import android.support.v4.app.Fragment; import android.support.v4.widget.SwipeRefreshLayout; +import android.support.v7.app.AlertDialog; import android.support.v7.widget.SearchView; import android.view.ActionMode; import android.view.LayoutInflater; @@ -47,7 +48,13 @@ import android.widget.LinearLayout; import android.widget.ListAdapter; import android.widget.TextView; +import android.widget.Toast; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastState; +import com.google.android.gms.cast.framework.CastStateListener; +import com.google.android.gms.cast.framework.IntroductoryOverlay; import com.squareup.otto.Subscribe; import org.amahi.anywhere.AmahiApplication; @@ -58,12 +65,14 @@ import org.amahi.anywhere.adapter.ServerFilesMetadataAdapter; import org.amahi.anywhere.bus.BusProvider; import org.amahi.anywhere.bus.FileOpeningEvent; +import org.amahi.anywhere.bus.ServerFileDeleteEvent; import org.amahi.anywhere.bus.ServerFileSharingEvent; import org.amahi.anywhere.bus.ServerFilesLoadFailedEvent; import org.amahi.anywhere.bus.ServerFilesLoadedEvent; import org.amahi.anywhere.server.client.ServerClient; import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.util.Android; import org.amahi.anywhere.util.Fragments; import org.amahi.anywhere.util.Mimes; import org.amahi.anywhere.util.ViewDirector; @@ -76,307 +85,397 @@ import javax.inject.Inject; -import static android.support.v4.content.PermissionChecker.checkSelfPermission; +import pub.devrel.easypermissions.AppSettingsDialog; +import pub.devrel.easypermissions.EasyPermissions; /** * Files fragment. Shows files list. */ -public class ServerFilesFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener, - AdapterView.OnItemClickListener, - AdapterView.OnItemLongClickListener, - ActionMode.Callback, - SearchView.OnQueryTextListener, - FilesFilterBaseAdapter.onFilterListChange -{ - private SearchView searchView; - private MenuItem searchMenuItem; - private LinearLayout mErrorLinearLayout; - - private static final class State - { - private State() { - } - - public static final String FILES = "files"; - public static final String FILES_SORT = "files_sort"; - } - - private static final int CALLBACK_NUMBER = 100; - - private enum FilesSort - { - NAME, MODIFICATION_TIME - } - - private FilesSort filesSort = FilesSort.MODIFICATION_TIME; - - private ActionMode filesActions; - - @Inject - ServerClient serverClient; - - @Override - public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { - View rootView; - if (!isMetadataAvailable()) { - rootView = layoutInflater.inflate(R.layout.fragment_server_files, container, false); - } else { - rootView = layoutInflater.inflate(R.layout.fragment_server_files_metadata, container, false); - } - mErrorLinearLayout = (LinearLayout) rootView.findViewById(R.id.error); - return rootView; - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - setUpInjections(); - - setUpFiles(savedInstanceState); - } - - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } - - private void setUpFiles(Bundle state) { - setUpFilesMenu(); - setUpFilesActions(); - setUpFilesAdapter(); - setUpFilesContent(state); - setUpFilesContentRefreshing(); - } - - private void setUpFilesMenu() { - setHasOptionsMenu(true); - } - - private void setUpFilesActions() { - getListView().setOnItemClickListener(this); - getListView().setOnItemLongClickListener(this); - } - - private AbsListView getListView() { - return (AbsListView) getView().findViewById(android.R.id.list); - } - - @Override - public boolean onItemLongClick(AdapterView filesListView, View fileView, int filePosition, long fileId) { - if (!areFilesActionsAvailable()) { - getListView().clearChoices(); - getListView().setItemChecked(filePosition, true); - - getListView().startActionMode(this); - - return true; - } else { - return false; - } - } - - private boolean areFilesActionsAvailable() { - return filesActions != null; - } - - @Override - public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { - this.filesActions = actionMode; - - actionMode.getMenuInflater().inflate(R.menu.action_mode_server_files, menu); - - return true; - } - - @Override - public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { - return false; - } - - @Override - public void onDestroyActionMode(ActionMode actionMode) { - this.filesActions = null; - - clearFileChoices(); - } - - private void clearFileChoices() { - getListView().clearChoices(); - getListView().requestLayout(); - } - - @RequiresApi(api = Build.VERSION_CODES.M) - @Override - public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { - switch (menuItem.getItemId()) { - case R.id.menu_share: - checkPermissions(); - break; - - default: - return false; - } - - actionMode.finish(); - - return true; - } - @RequiresApi(api = Build.VERSION_CODES.M) - private void checkPermissions(){ - int permissionCheck = checkSelfPermission(getActivity().getApplicationContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE); - - if (!(permissionCheck == PackageManager.PERMISSION_GRANTED)) { - - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, CALLBACK_NUMBER); - - } else { - startFileSharing(getCheckedFile()); - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) { - switch (requestCode) { - case 100: { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Snackbar.make(getView(),getString(R.string.share_permission_granted),Snackbar.LENGTH_LONG).show(); - } else { - Snackbar.make(getView(),getString(R.string.share_permission_denied),Snackbar.LENGTH_LONG) - .setAction("Permissions", new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent intent = new Intent(); - intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", getActivity().getPackageName(), null); - intent.setData(uri); - startActivity(intent); - } - }) - .show(); - } - } - } - } - - private void startFileSharing(ServerFile file) { - BusProvider.getBus().post(new ServerFileSharingEvent(getShare(), file)); - } - - private ServerFile getCheckedFile() { - return getFile(getListView().getCheckedItemPosition()); - } - - private ServerFile getFile(int position) { - if (!isMetadataAvailable()) { - return getFilesAdapter().getItem(position); - } else { - return getFilesMetadataAdapter().getItem(position); - } - } - - private boolean isMetadataAvailable() { - return ServerShare.Tag.MOVIES.equals(getShare().getTag()); - } - - private ServerFilesAdapter getFilesAdapter() { - return (ServerFilesAdapter) getListAdapter(); - } - - private ServerFilesMetadataAdapter getFilesMetadataAdapter() { - return (ServerFilesMetadataAdapter) getListAdapter(); - } - - private ListAdapter getListAdapter() { - return getListView().getAdapter(); - } - - private void setUpFilesAdapter() { - if (!isMetadataAvailable()) { - setListAdapter(new ServerFilesAdapter(getActivity(), serverClient)); - } else { - setListAdapter(new ServerFilesMetadataAdapter(getActivity(), serverClient)); - } - } - - private void setListAdapter(FilesFilterBaseAdapter adapter) { - adapter.setFilterListChangeListener(this); - getListView().setAdapter(adapter); - } - - private void setUpFilesContent(Bundle state) { - if (isFilesStateValid(state)) { - setUpFilesState(state); - } else { - setUpFilesContent(); - } - } - - private boolean isFilesStateValid(Bundle state) { - return (state != null) && state.containsKey(State.FILES) && state.containsKey(State.FILES_SORT); - } - - private void setUpFilesState(Bundle state) { - List files = state.getParcelableArrayList(State.FILES); - - FilesSort filesSort = (FilesSort) state.getSerializable(State.FILES_SORT); - - setUpFilesContent(files); - setUpFilesContentSort(filesSort); - - showFilesContent(); - } - - private void setUpFilesContent(List files) { - if (!isMetadataAvailable()) { - getFilesAdapter().replaceWith(getShare(), files); - } else { - getFilesMetadataAdapter().replaceWith(getShare(), getMetadataFiles(files)); - } - } - - private List getMetadataFiles(List files) { - List metadataFiles = new ArrayList(); - - for (ServerFile file : files) { - if (Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY) { - metadataFiles.add(file); - } - - if (Mimes.match(file.getMime()) == Mimes.Type.VIDEO) { - metadataFiles.add(file); - } - } - return metadataFiles; - } - - private void setUpFilesContentSort(FilesSort filesSort) { - this.filesSort = filesSort; - - getActivity().invalidateOptionsMenu(); - } - - private void showFilesContent() { - if (areFilesAvailable()) { - getView().findViewById(android.R.id.list).setVisibility(View.VISIBLE); - getView().findViewById(android.R.id.empty).setVisibility(View.INVISIBLE); - } else { - getView().findViewById(android.R.id.list).setVisibility(View.INVISIBLE); - getView().findViewById(android.R.id.empty).setVisibility(View.VISIBLE); - } - - ViewDirector.of(this, R.id.animator).show(R.id.content); - } - - private boolean areFilesAvailable() { - if (!isMetadataAvailable()) { - return !getFilesAdapter().isEmpty(); - } else { - return !getFilesMetadataAdapter().isEmpty(); - } - } - - private void setUpFilesContent() { - if (serverClient.isConnected()){ + +public class ServerFilesFragment extends Fragment implements + SwipeRefreshLayout.OnRefreshListener, + AdapterView.OnItemClickListener, + AdapterView.OnItemLongClickListener, + ActionMode.Callback, + SearchView.OnQueryTextListener, + FilesFilterBaseAdapter.onFilterListChange, + EasyPermissions.PermissionCallbacks, + CastStateListener { + private static final int SHARE_PERMISSIONS = 101; + @Inject + ServerClient serverClient; + private SearchView searchView; + private MenuItem searchMenuItem; + private LinearLayout mErrorLinearLayout; + private CastContext mCastContext; + private IntroductoryOverlay mIntroductoryOverlay; + private MenuItem mediaRouteMenuItem; + private ProgressDialog deleteProgressDialog; + private int deleteFilePosition; + private int lastCheckedFileIndex = -1; + private FilesSort filesSort = FilesSort.MODIFICATION_TIME; + private ActionMode filesActions; + + @Override + public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { + View rootView; + if (!isMetadataAvailable()) { + rootView = layoutInflater.inflate(R.layout.fragment_server_files, container, false); + } else { + rootView = layoutInflater.inflate(R.layout.fragment_server_files_metadata, container, false); + } + mErrorLinearLayout = (LinearLayout) rootView.findViewById(R.id.error); + return rootView; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + setUpInjections(); + + setUpCast(); + + setUpFiles(savedInstanceState); + + setUpProgressDialog(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setUpCast() { + mCastContext = CastContext.getSharedInstance(getActivity()); + } + + @Override + public void onCastStateChanged(int newState) { + if (newState != CastState.NO_DEVICES_AVAILABLE) { + showIntroductoryOverlay(); + } + } + + private void showIntroductoryOverlay() { + if (mIntroductoryOverlay != null) { + mIntroductoryOverlay.remove(); + } + if ((mediaRouteMenuItem != null) && mediaRouteMenuItem.isVisible()) { + new Handler().post(new Runnable() { + @Override + public void run() { + mIntroductoryOverlay = new IntroductoryOverlay + .Builder(getActivity(), mediaRouteMenuItem) + .setTitleText("Introducing Cast") + .setSingleTime() + .setOnOverlayDismissedListener( + new IntroductoryOverlay.OnOverlayDismissedListener() { + @Override + public void onOverlayDismissed() { + mIntroductoryOverlay = null; + } + }) + .build(); + mIntroductoryOverlay.show(); + } + }); + } + } + + private void setUpFiles(Bundle state) { + setUpFilesMenu(); + setUpFilesActions(); + setUpFilesAdapter(); + setUpFilesContent(state); + setUpFilesContentRefreshing(); + } + + private void setUpProgressDialog() { + deleteProgressDialog = new ProgressDialog(getContext()); + deleteProgressDialog.setMessage(getString(R.string.message_delete_progress)); + deleteProgressDialog.setIndeterminate(true); + deleteProgressDialog.setCancelable(false); + } + + private void setUpFilesMenu() { + setHasOptionsMenu(true); + } + + private void setUpFilesActions() { + getListView().setOnItemClickListener(this); + getListView().setOnItemLongClickListener(this); + } + + private AbsListView getListView() { + return (AbsListView) getView().findViewById(android.R.id.list); + } + + @Override + public boolean onItemLongClick(AdapterView filesListView, View fileView, int filePosition, long fileId) { + if (!areFilesActionsAvailable()) { + getListView().clearChoices(); + getListView().setItemChecked(filePosition, true); + + getListView().startActionMode(this); + + return true; + } else { + return false; + } + } + + private boolean areFilesActionsAvailable() { + return filesActions != null; + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + this.filesActions = actionMode; + + actionMode.getMenuInflater().inflate(R.menu.action_mode_server_files, menu); + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + this.filesActions = null; + + clearFileChoices(); + } + + private void clearFileChoices() { + getListView().clearChoices(); + getListView().requestLayout(); + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.menu_share: + if (Android.isPermissionRequired()) { + checkSharePermissions(actionMode); + } else { + startFileSharing(getCheckedFile()); + actionMode.finish(); + } + break; + case R.id.menu_delete: + deleteFile(getCheckedFile(), actionMode); + break; + default: + return false; + } + + return true; + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private void checkSharePermissions(ActionMode actionMode) { + String[] perms = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; + if (EasyPermissions.hasPermissions(getContext(), perms)) { + startFileSharing(getCheckedFile()); + } else { + lastCheckedFileIndex = getListView().getCheckedItemPosition(); + EasyPermissions.requestPermissions(this, getString(R.string.share_permission), + SHARE_PERMISSIONS, perms); + } + actionMode.finish(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + // Forward results to EasyPermissions + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @Override + public void onPermissionsGranted(int requestCode, List perms) { + if (requestCode == SHARE_PERMISSIONS) { + if (lastCheckedFileIndex != -1) { + startFileSharing(getFile(lastCheckedFileIndex)); + } + } + } + + @Override + public void onPermissionsDenied(int requestCode, List perms) { + if (EasyPermissions.somePermissionPermanentlyDenied(this, perms)) { + if (requestCode == SHARE_PERMISSIONS) { + showPermissionSnackBar(getString(R.string.share_permission_denied)); + } + } + + } + + private void showPermissionSnackBar(String message) { + Snackbar.make(getView(), message, Snackbar.LENGTH_LONG) + .setAction(R.string.menu_settings, new View.OnClickListener() { + @Override + public void onClick(View v) { + new AppSettingsDialog.Builder(ServerFilesFragment.this).build().show(); + } + }) + .show(); + } + + private void startFileSharing(ServerFile file) { + BusProvider.getBus().post(new ServerFileSharingEvent(getShare(), file)); + } + + private void deleteFile(final ServerFile file, final ActionMode actionMode) { + deleteFilePosition = getListView().getCheckedItemPosition(); + new AlertDialog.Builder(getContext()) + .setTitle(R.string.message_delete_file_title) + .setMessage(R.string.message_delete_file_body) + .setPositiveButton(R.string.button_yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + deleteProgressDialog.show(); + serverClient.deleteFile(getShare(), file); + actionMode.finish(); + } + }) + .setNegativeButton(R.string.button_no, null) + .show(); + } + + @Subscribe + public void onFileDeleteEvent(ServerFileDeleteEvent fileDeleteEvent) { + deleteProgressDialog.dismiss(); + if (fileDeleteEvent.isDeleted()) { + if (!isMetadataAvailable()) { + getFilesAdapter().removeFile(deleteFilePosition); + } else { + getFilesMetadataAdapter().removeFile(deleteFilePosition); + } + } else { + Toast.makeText(getContext(), R.string.message_delete_file_error, Toast.LENGTH_SHORT).show(); + } + } + + private ServerFile getCheckedFile() { + return getFile(getListView().getCheckedItemPosition()); + } + + private ServerFile getFile(int position) { + if (!isMetadataAvailable()) { + return getFilesAdapter().getItem(position); + } else { + return getFilesMetadataAdapter().getItem(position); + } + } + + private boolean isMetadataAvailable() { + return ServerShare.Tag.MOVIES.equals(getShare().getTag()); + } + + private ServerFilesAdapter getFilesAdapter() { + return (ServerFilesAdapter) getListAdapter(); + } + + private ServerFilesMetadataAdapter getFilesMetadataAdapter() { + return (ServerFilesMetadataAdapter) getListAdapter(); + } + + private ListAdapter getListAdapter() { + return getListView().getAdapter(); + } + + private void setListAdapter(FilesFilterBaseAdapter adapter) { + adapter.setFilterListChangeListener(this); + getListView().setAdapter(adapter); + } + + private void setUpFilesAdapter() { + if (!isMetadataAvailable()) { + setListAdapter(new ServerFilesAdapter(getActivity(), getActivity().getApplicationContext(), serverClient)); + } else { + setListAdapter(new ServerFilesMetadataAdapter(getActivity(), serverClient)); + } + } + + private void setUpFilesContent(Bundle state) { + if (isFilesStateValid(state)) { + setUpFilesState(state); + } else { + setUpFilesContent(); + } + } + + private boolean isFilesStateValid(Bundle state) { + return (state != null) && state.containsKey(State.FILES) && state.containsKey(State.FILES_SORT); + } + + private void setUpFilesState(Bundle state) { + List files = state.getParcelableArrayList(State.FILES); + + FilesSort filesSort = (FilesSort) state.getSerializable(State.FILES_SORT); + + setUpFilesContent(files); + setUpFilesContentSort(filesSort); + + showFilesContent(); + } + + private void setUpFilesContent(List files) { + if (!isMetadataAvailable()) { + getFilesAdapter().replaceWith(getShare(), files); + } else { + getFilesMetadataAdapter().replaceWith(getShare(), getMetadataFiles(files)); + } + } + + private List getMetadataFiles(List files) { + List metadataFiles = new ArrayList(); + + for (ServerFile file : files) { + if (Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY) { + metadataFiles.add(file); + } + + if (Mimes.match(file.getMime()) == Mimes.Type.VIDEO) { + metadataFiles.add(file); + } + } + return metadataFiles; + } + + private void setUpFilesContentSort(FilesSort filesSort) { + this.filesSort = filesSort; + + getActivity().invalidateOptionsMenu(); + } + + private void showFilesContent() { + if (areFilesAvailable()) { + getView().findViewById(android.R.id.list).setVisibility(View.VISIBLE); + getView().findViewById(android.R.id.empty).setVisibility(View.INVISIBLE); + } else { + getView().findViewById(android.R.id.list).setVisibility(View.INVISIBLE); + getView().findViewById(android.R.id.empty).setVisibility(View.VISIBLE); + } + + ViewDirector.of(this, R.id.animator).show(R.id.content); + } + + private boolean areFilesAvailable() { + if (!isMetadataAvailable()) { + return !getFilesAdapter().isEmpty(); + } else { + return !getFilesMetadataAdapter().isEmpty(); + } + } + + private void setUpFilesContent() { + if (serverClient.isConnected()) { if (!isDirectoryAvailable()) { serverClient.getFiles(getShare()); } else { @@ -385,307 +484,338 @@ private void setUpFilesContent() { } } - private boolean isDirectoryAvailable() { - return getDirectory() != null; - } + private boolean isDirectoryAvailable() { + return getDirectory() != null; + } + + private boolean isDirectory(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY; + } + + private ServerFile getDirectory() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); + } + + private ServerShare getShare() { + return getArguments().getParcelable(Fragments.Arguments.SERVER_SHARE); + } + + @Subscribe + public void onFilesLoaded(ServerFilesLoadedEvent event) { + showFilesContent(event.getServerFiles()); + } + + private void showFilesContent(List files) { + setUpFilesContent(sortFiles(files)); + + showFilesContent(); + + hideFilesContentRefreshing(); + } + + private List sortFiles(List files) { + List sortedFiles = new ArrayList(files); + + Collections.sort(sortedFiles, getFilesComparator()); + + return sortedFiles; + } + + private Comparator getFilesComparator() { + switch (filesSort) { + case NAME: + return new FileNameComparator(); + + case MODIFICATION_TIME: + return new FileModificationTimeComparator(); + + default: + return null; + } + } + + private void hideFilesContentRefreshing() { + getRefreshLayout().setRefreshing(false); + } + + private SwipeRefreshLayout getRefreshLayout() { + return (SwipeRefreshLayout) getView().findViewById(R.id.layout_refresh); + } + + @Subscribe + public void onFilesLoadFailed(ServerFilesLoadFailedEvent event) { + showFilesError(); + + hideFilesContentRefreshing(); + } + + private void showFilesError() { + ViewDirector.of(this, R.id.animator).show(R.id.error); + mErrorLinearLayout.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + ViewDirector.of(getActivity(), R.id.animator).show(android.R.id.progress); + setUpFilesContent(); + } + }); + } + + private void setUpFilesContentRefreshing() { + SwipeRefreshLayout refreshLayout = getRefreshLayout(); + + refreshLayout.setColorSchemeResources( + android.R.color.holo_blue_light, + android.R.color.holo_orange_light, + android.R.color.holo_green_light, + android.R.color.holo_red_light); + + refreshLayout.setOnRefreshListener(this); + } + + @Override + public void onRefresh() { + setUpFilesContent(); + } + + @Override + public void onItemClick(AdapterView filesListView, View fileView, int filePosition, long fileId) { + if (!areFilesActionsAvailable()) { + collapseSearchView(); + startFileOpening(getFile(filePosition)); + + if (isDirectory(getFile(filePosition))) { + setUpTitle(getFile(filePosition).getName()); + } + } + } + + private void startFileOpening(ServerFile file) { + BusProvider.getBus().post(new FileOpeningEvent(getShare(), getFiles(), file)); + } + + private void setUpTitle(String title) { + ((ServerFilesActivity) getActivity()).getSupportActionBar().setTitle(title); + } + + private List getFiles() { + if (!isMetadataAvailable()) { + return getFilesAdapter().getItems(); + } else { + return getFilesMetadataAdapter().getItems(); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { + super.onCreateOptionsMenu(menu, menuInflater); + + menuInflater.inflate(R.menu.action_bar_server_files, menu); + + mediaRouteMenuItem = CastButtonFactory.setUpMediaRouteButton( + getActivity().getApplicationContext(), + menu, R.id.media_route_menu_item); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + + setUpFilesContentSortIcon(menu.findItem(R.id.menu_sort)); + searchMenuItem = menu.findItem(R.id.menu_search); + searchView = (SearchView) searchMenuItem.getActionView(); + + setUpSearchView(); + setSearchCursor(); + } + + private void setUpSearchView() { + SearchManager searchManager = (SearchManager) getActivity().getSystemService(Context.SEARCH_SERVICE); + searchView.setSearchableInfo(searchManager.getSearchableInfo(getActivity().getComponentName())); + searchView.setSubmitButtonEnabled(false); + searchView.setOnQueryTextListener(this); + } + + private void setSearchCursor() { + final int textViewID = searchView.getContext().getResources().getIdentifier("android:id/search_src_text", null, null); + final AutoCompleteTextView searchTextView = (AutoCompleteTextView) searchView.findViewById(textViewID); + try { + Field mCursorDrawableRes = TextView.class.getDeclaredField("mCursorDrawableRes"); + mCursorDrawableRes.setAccessible(true); + mCursorDrawableRes.set(searchTextView, R.drawable.white_cursor); + } catch (Exception ignored) { + } + } + + private void setUpFilesContentSortIcon(MenuItem menuItem) { + switch (filesSort) { + case NAME: + menuItem.setIcon(R.drawable.ic_menu_sort_name); + break; + + case MODIFICATION_TIME: + menuItem.setIcon(R.drawable.ic_menu_sort_modification_time); + break; + + default: + break; + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch (menuItem.getItemId()) { + + case R.id.menu_sort: + setUpFilesContentSortSwitched(); + setUpFilesContentSortIcon(menuItem); + return true; + default: + return super.onOptionsItemSelected(menuItem); + } + } + + private void setUpFilesContentSortSwitched() { + switch (filesSort) { + case NAME: + filesSort = FilesSort.MODIFICATION_TIME; + break; + + case MODIFICATION_TIME: + filesSort = FilesSort.NAME; + break; + + default: + break; + } + + setUpFilesContentSort(); + } + + private void setUpFilesContentSort() { + if (!isMetadataAvailable()) { + getFilesAdapter().replaceWith(getShare(), sortFiles(getFiles())); + } else { + getFilesMetadataAdapter().replaceWith(getShare(), sortFiles(getFiles())); + } + } + + @Override + public boolean onQueryTextSubmit(String s) { + return false; + } + + @Override + public boolean onQueryTextChange(String s) { + if (!isMetadataAvailable()) { + getFilesAdapter().getFilter().filter(s); + } else { + getFilesMetadataAdapter().getFilter().filter(s); + } + return true; + } + + @Override + public void isListEmpty(boolean empty) { + if (getView().findViewById(R.id.none_text) != null) + getView().findViewById(R.id.none_text).setVisibility(empty ? View.VISIBLE : View.GONE); + } + + private void collapseSearchView() { + if (searchView.isShown()) { + searchMenuItem.collapseActionView(); + searchView.setQuery("", false); + } + } + + public boolean checkForDuplicateFile(String fileName) { + List files; + + if (!isMetadataAvailable()) { + files = getFilesAdapter().getItems(); + } else { + files = getFilesMetadataAdapter().getItems(); + } + for (ServerFile serverFile : files) { + if (serverFile.getName().equals(fileName)) { + return true; + } + } + return false; + } + + @Override + public void onResume() { + super.onResume(); - private boolean isDirectory(ServerFile file) { - return Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY; - } + mCastContext.addCastStateListener(this); + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + + mCastContext.removeCastStateListener(this); + BusProvider.getBus().unregister(this); + } - private ServerFile getDirectory() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_FILE); - } + @Override + public void onDestroyView() { + super.onDestroyView(); + if (isMetadataAvailable()) { + getFilesMetadataAdapter().tearDownCallbacks(); + } + } - private ServerShare getShare() { - return getArguments().getParcelable(Fragments.Arguments.SERVER_SHARE); - } + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); - @Subscribe - public void onFilesLoaded(ServerFilesLoadedEvent event) { - showFilesContent(event.getServerFiles()); - } + tearDownFilesState(outState); + } - private void showFilesContent(List files) { - setUpFilesContent(sortFiles(files)); + private void tearDownFilesState(Bundle state) { + if (areFilesLoaded()) { + state.putParcelableArrayList(State.FILES, new ArrayList(getFiles())); + } - showFilesContent(); + state.putSerializable(State.FILES_SORT, filesSort); + } - hideFilesContentRefreshing(); - } + private boolean areFilesLoaded() { + if (getView() == null) { + return false; + } - private List sortFiles(List files) { - List sortedFiles = new ArrayList(files); + if (!isMetadataAvailable()) { + return getFilesAdapter() != null; + } else { + return getFilesMetadataAdapter() != null; + } + } - Collections.sort(sortedFiles, getFilesComparator()); + private enum FilesSort { + NAME, MODIFICATION_TIME + } - return sortedFiles; - } + private static final class State { + public static final String FILES = "files"; + public static final String FILES_SORT = "files_sort"; + private State() { + } + } - private Comparator getFilesComparator() { - switch (filesSort) { - case NAME: - return new FileNameComparator(); + private static final class FileNameComparator implements Comparator { + @Override + public int compare(ServerFile firstFile, ServerFile secondFile) { + return firstFile.getName().compareTo(secondFile.getName()); + } + } - case MODIFICATION_TIME: - return new FileModificationTimeComparator(); - - default: - return null; - } - } - - private void hideFilesContentRefreshing() { - getRefreshLayout().setRefreshing(false); - } - - private SwipeRefreshLayout getRefreshLayout() { - return (SwipeRefreshLayout) getView().findViewById(R.id.layout_refresh); - } - - @Subscribe - public void onFilesLoadFailed(ServerFilesLoadFailedEvent event) { - showFilesError(); - - hideFilesContentRefreshing(); - } - - private void showFilesError() { - ViewDirector.of(this, R.id.animator).show(R.id.error); - mErrorLinearLayout.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - ViewDirector.of(getActivity(), R.id.animator).show(android.R.id.progress); - setUpFilesContent(); - } - }); - } - - private void setUpFilesContentRefreshing() { - SwipeRefreshLayout refreshLayout = getRefreshLayout(); - - refreshLayout.setColorSchemeResources( - android.R.color.holo_blue_light, - android.R.color.holo_orange_light, - android.R.color.holo_green_light, - android.R.color.holo_red_light); - - refreshLayout.setOnRefreshListener(this); - } - - @Override - public void onRefresh() { - setUpFilesContent(); - } - - @Override - public void onItemClick(AdapterView filesListView, View fileView, int filePosition, long fileId) { - if (!areFilesActionsAvailable()) { - collapseSearchView(); - startFileOpening(getFile(filePosition)); - - if(isDirectory(getFile(filePosition))){ - setUpTitle(getFile(filePosition).getName()); - } - } - } - - private void startFileOpening(ServerFile file) { - BusProvider.getBus().post(new FileOpeningEvent(getShare(), getFiles(), file)); - } - - private void setUpTitle(String title) { - ((ServerFilesActivity)getActivity()).getSupportActionBar().setTitle(title); - } - - private List getFiles() { - if (!isMetadataAvailable()) { - return getFilesAdapter().getItems(); - } else { - return getFilesMetadataAdapter().getItems(); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) { - super.onCreateOptionsMenu(menu, menuInflater); - - menuInflater.inflate(R.menu.action_bar_server_files, menu); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - super.onPrepareOptionsMenu(menu); - - setUpFilesContentSortIcon(menu.findItem(R.id.menu_sort)); - searchMenuItem = menu.findItem(R.id.menu_search); - searchView = (SearchView) searchMenuItem.getActionView(); - - setUpSearchView(); - setSearchCursor(); - } - - private void setUpSearchView() { - SearchManager searchManager = (SearchManager) getActivity().getSystemService(Context.SEARCH_SERVICE); - searchView.setSearchableInfo(searchManager.getSearchableInfo(getActivity().getComponentName())); - searchView.setSubmitButtonEnabled(false); - searchView.setOnQueryTextListener(this); - } - - private void setSearchCursor() { - final int textViewID = searchView.getContext().getResources().getIdentifier("android:id/search_src_text",null, null); - final AutoCompleteTextView searchTextView = (AutoCompleteTextView) searchView.findViewById(textViewID); - try { - Field mCursorDrawableRes = TextView.class.getDeclaredField("mCursorDrawableRes"); - mCursorDrawableRes.setAccessible(true); - mCursorDrawableRes.set(searchTextView, R.drawable.white_cursor); - } catch (Exception ignored) {} - } - - private void setUpFilesContentSortIcon(MenuItem menuItem) { - switch (filesSort) { - case NAME: - menuItem.setIcon(R.drawable.ic_menu_sort_name); - break; - - case MODIFICATION_TIME: - menuItem.setIcon(R.drawable.ic_menu_sort_modification_time); - break; - - default: - break; - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - - case R.id.menu_sort: - setUpFilesContentSortSwitched(); - setUpFilesContentSortIcon(menuItem); - return true; - - default: - return super.onOptionsItemSelected(menuItem); - } - } - - private void setUpFilesContentSortSwitched() { - switch (filesSort) { - case NAME: - filesSort = FilesSort.MODIFICATION_TIME; - break; - - case MODIFICATION_TIME: - filesSort = FilesSort.NAME; - break; - - default: - break; - } - - setUpFilesContentSort(); - } - - private void setUpFilesContentSort() { - if (!isMetadataAvailable()) { - getFilesAdapter().replaceWith(getShare(), sortFiles(getFiles())); - } else { - getFilesMetadataAdapter().replaceWith(getShare(), sortFiles(getFiles())); - } - } - - @Override - public boolean onQueryTextSubmit(String s) { - return false; - } - - @Override - public boolean onQueryTextChange(String s) { - if (!isMetadataAvailable()) { - getFilesAdapter().getFilter().filter(s); - } else { - getFilesMetadataAdapter().getFilter().filter(s); - } - return true; - } - - @Override - public void isListEmpty(boolean empty) { - if(getView().findViewById(R.id.none_text)!=null) - getView().findViewById(R.id.none_text).setVisibility(empty?View.VISIBLE:View.GONE); - } - - private void collapseSearchView() { - if (searchView.isShown()) { - searchMenuItem.collapseActionView(); - searchView.setQuery("", false); - } - } - - @Override - public void onResume() { - super.onResume(); - - BusProvider.getBus().register(this); - } - - @Override - public void onPause() { - super.onPause(); - - BusProvider.getBus().unregister(this); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (isMetadataAvailable()) { - getFilesMetadataAdapter().tearDownCallbacks(); - } - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - tearDownFilesState(outState); - } - - private void tearDownFilesState(Bundle state) { - if (areFilesLoaded()) { - state.putParcelableArrayList(State.FILES, new ArrayList(getFiles())); - } - - state.putSerializable(State.FILES_SORT, filesSort); - } - - private boolean areFilesLoaded() { - if (getView() == null) { - return false; - } - - if (!isMetadataAvailable()) { - return getFilesAdapter() != null; - } else { - return getFilesMetadataAdapter() != null; - } - } - - private static final class FileNameComparator implements Comparator - { - @Override - public int compare(ServerFile firstFile, ServerFile secondFile) { - return firstFile.getName().compareTo(secondFile.getName()); - } - } - - private static final class FileModificationTimeComparator implements Comparator - { - @Override - public int compare(ServerFile firstFile, ServerFile secondFile) { - return -firstFile.getModificationTime().compareTo(secondFile.getModificationTime()); - } - } + private static final class FileModificationTimeComparator implements Comparator { + @Override + public int compare(ServerFile firstFile, ServerFile secondFile) { + return -firstFile.getModificationTime().compareTo(secondFile.getModificationTime()); + } + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/ServerSharesFragment.java b/src/main/java/org/amahi/anywhere/fragment/ServerSharesFragment.java index 666b8a59c..68d9fdad4 100644 --- a/src/main/java/org/amahi/anywhere/fragment/ServerSharesFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/ServerSharesFragment.java @@ -51,137 +51,125 @@ /** * Shares fragment. Shows shares list. */ -public class ServerSharesFragment extends Fragment -{ - private static final class State - { - private State() { - } +public class ServerSharesFragment extends Fragment { + @Inject + ServerClient serverClient; + private RecyclerView mRecyclerView; - public static final String SHARES = "shares"; - } + private ServerSharesAdapter mServerSharesAdapter; - private RecyclerView mRecyclerView; - - private ServerSharesAdapter mServerSharesAdapter; - - private LinearLayout mEmptyLinearLayout; + private LinearLayout mEmptyLinearLayout; private LinearLayout mErrorLinearLayout; - @Inject - ServerClient serverClient; - - @Override - public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { + @Override + public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) { - View rootView = layoutInflater.inflate(R.layout.fragment_server_shares, container, false); + View rootView = layoutInflater.inflate(R.layout.fragment_server_shares, container, false); - mRecyclerView = (RecyclerView)rootView.findViewById(R.id.list); + mRecyclerView = (RecyclerView) rootView.findViewById(R.id.list); - mServerSharesAdapter = new ServerSharesAdapter(getActivity()); + mServerSharesAdapter = new ServerSharesAdapter(getActivity()); - mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity(),LinearLayoutManager.VERTICAL,false)); + mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false)); - mEmptyLinearLayout = (LinearLayout)rootView.findViewById(R.id.empty); + mEmptyLinearLayout = (LinearLayout) rootView.findViewById(R.id.empty); mErrorLinearLayout = (LinearLayout) rootView.findViewById(R.id.error); - mRecyclerView.addItemDecoration(new - DividerItemDecoration(getActivity(), - DividerItemDecoration.VERTICAL)); + mRecyclerView.addItemDecoration(new + DividerItemDecoration(getActivity(), + DividerItemDecoration.VERTICAL)); - return rootView; - } + return rootView; + } - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); - setUpInjections(); + setUpInjections(); - setUpShares(savedInstanceState); - } + setUpShares(savedInstanceState); + } - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } - private void setUpShares(Bundle state) { - setUpSharesAdapter(); + private void setUpShares(Bundle state) { + setUpSharesAdapter(); - setUpSharesContent(state); - } + setUpSharesContent(state); + } - private void setUpSharesAdapter() { - mRecyclerView.setAdapter(mServerSharesAdapter); - } + private void setUpSharesAdapter() { + mRecyclerView.setAdapter(mServerSharesAdapter); + } - private void setUpSharesContent(Bundle state) { - if (isSharesStateValid(state)) { - setUpSharesState(state); - } else { - setUpSharesContent(); - } - } + private void setUpSharesContent(Bundle state) { + if (isSharesStateValid(state)) { + setUpSharesState(state); + } else { + setUpSharesContent(); + } + } - private boolean isSharesStateValid(Bundle state) { - return (state != null) && (state.containsKey(State.SHARES)); - } + private boolean isSharesStateValid(Bundle state) { + return (state != null) && (state.containsKey(State.SHARES)); + } - private void setUpSharesState(Bundle state) { - List shares = state.getParcelableArrayList(State.SHARES); - if(shares!=null) { - setUpSharesContent(shares); + private void setUpSharesState(Bundle state) { + List shares = state.getParcelableArrayList(State.SHARES); + if (shares != null) { + setUpSharesContent(shares); - showSharesContent(); + showSharesContent(); - mEmptyLinearLayout.setVisibility(View.GONE); - } - else{ + mEmptyLinearLayout.setVisibility(View.GONE); + } else { - mEmptyLinearLayout.setVisibility(View.VISIBLE); + mEmptyLinearLayout.setVisibility(View.VISIBLE); - } - } + } + } - private void setUpSharesContent(List shares) { - getSharesAdapter().replaceWith(shares); - } + private void setUpSharesContent(List shares) { + getSharesAdapter().replaceWith(shares); + } - private ServerSharesAdapter getSharesAdapter() { - return mServerSharesAdapter; - } + private ServerSharesAdapter getSharesAdapter() { + return mServerSharesAdapter; + } - private void showSharesContent() { - ViewDirector.of(getActivity(), R.id.animator).show(R.id.content); - } + private void showSharesContent() { + ViewDirector.of(getActivity(), R.id.animator).show(R.id.content); + } - private void setUpSharesContent() { - if (serverClient.isConnected()) { - serverClient.getShares(); - } - } + private void setUpSharesContent() { + if (serverClient.isConnected()) { + serverClient.getShares(); + } + } - @Subscribe - public void onServerConnectionChanged(ServerConnectionChangedEvent event) { - serverClient.getShares(); - } + @Subscribe + public void onServerConnectionChanged(ServerConnectionChangedEvent event) { + serverClient.getShares(); + } - @Subscribe - public void onSharesLoaded(ServerSharesLoadedEvent event) { - setUpSharesContent(event.getServerShares()); + @Subscribe + public void onSharesLoaded(ServerSharesLoadedEvent event) { + setUpSharesContent(event.getServerShares()); + showSharesContent(); + } - showSharesContent(); - } + @Subscribe + public void onSharesLoadFailed(ServerSharesLoadFailedEvent event) { + showSharesError(); + } - @Subscribe - public void onSharesLoadFailed(ServerSharesLoadFailedEvent event) { - showSharesError(); - } - - private void showSharesError() { - ViewDirector.of(getActivity(), R.id.animator).show(R.id.error); + private void showSharesError() { + ViewDirector.of(getActivity(), R.id.animator).show(R.id.error); mErrorLinearLayout.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -189,36 +177,43 @@ public void onClick(View view) { setUpSharesContent(); } }); - } + } + + @Override + public void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } - @Override - public void onResume() { - super.onResume(); + @Override + public void onPause() { + super.onPause(); - BusProvider.getBus().register(this); - } + BusProvider.getBus().unregister(this); + } - @Override - public void onPause() { - super.onPause(); + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); - BusProvider.getBus().unregister(this); - } + tearDownSharesState(outState); + } - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); + private void tearDownSharesState(Bundle state) { + if (areSharesLoaded()) { + state.putParcelableArrayList(State.SHARES, new ArrayList(getSharesAdapter().getItems())); + } + } - tearDownSharesState(outState); - } + private boolean areSharesLoaded() { + return getSharesAdapter() != null; + } - private void tearDownSharesState(Bundle state) { - if (areSharesLoaded()) { - state.putParcelableArrayList(State.SHARES, new ArrayList(getSharesAdapter().getItems())); - } - } + private static final class State { + public static final String SHARES = "shares"; - private boolean areSharesLoaded() { - return getSharesAdapter() != null; - } -} \ No newline at end of file + private State() { + } + } +} diff --git a/src/main/java/org/amahi/anywhere/fragment/SettingsFragment.java b/src/main/java/org/amahi/anywhere/fragment/SettingsFragment.java index 236308d78..3df7f3b24 100644 --- a/src/main/java/org/amahi/anywhere/fragment/SettingsFragment.java +++ b/src/main/java/org/amahi/anywhere/fragment/SettingsFragment.java @@ -29,6 +29,7 @@ import android.preference.ListPreference; import android.preference.Preference; import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.design.widget.Snackbar; import android.widget.Toast; @@ -37,6 +38,8 @@ import org.amahi.anywhere.R; import org.amahi.anywhere.account.AmahiAccount; import org.amahi.anywhere.activity.NavigationActivity; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.UploadSettingsOpeningEvent; import org.amahi.anywhere.server.ApiConnection; import org.amahi.anywhere.server.client.ServerClient; import org.amahi.anywhere.util.Android; @@ -51,230 +54,243 @@ * Settings fragment. Shows application's settings. */ public class SettingsFragment extends PreferenceFragment implements Preference.OnPreferenceClickListener, - SharedPreferences.OnSharedPreferenceChangeListener, - AccountManagerCallback -{ - @Inject - ServerClient serverClient; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setUpInjections(); - setUpSettings(); - } - - private void setUpInjections() { - AmahiApplication.from(getActivity()).inject(this); - } - - private void setUpSettings() { - setUpSettingsContent(); - setUpSettingsSummary(); - setUpSettingsListeners(); - } - - private void setUpSettingsContent() { - addPreferencesFromResource(R.xml.settings); - } - - private void setUpSettingsSummary() { - ListPreference serverConnection = (ListPreference) getPreference(R.string.preference_key_server_connection); - Preference applicationVersion = getPreference(R.string.preference_key_about_version); - - serverConnection.setSummary(getServerConnectionSummary()); - applicationVersion.setSummary(getApplicationVersionSummary()); - } - - private String getServerConnectionSummary() { - ListPreference serverConnection = (ListPreference) getPreference(R.string.preference_key_server_connection); - - return String.format("%s", serverConnection.getEntry()); - } - - private Preference getPreference(int id){ - return findPreference(getString(id)); - } - - private String getApplicationVersionSummary() { - return String.format( - "Amahi for Android %s\nwww.amahi.org/android", - Android.getApplicationVersion()); - } - - private void setUpSettingsListeners() { - Preference accountSignOut = getPreference(R.string.preference_key_account_sign_out); - Preference applicationVersion = getPreference(R.string.preference_key_about_version); - Preference applicationFeedback = getPreference(R.string.preference_key_about_feedback); - Preference applicationRating = getPreference(R.string.preference_key_about_rating); - Preference shareApp = getPreference(R.string.preference_key_tell_a_friend); - - accountSignOut.setOnPreferenceClickListener(this); - applicationVersion.setOnPreferenceClickListener(this); - applicationFeedback.setOnPreferenceClickListener(this); - applicationRating.setOnPreferenceClickListener(this); - shareApp.setOnPreferenceClickListener(this); - } - - @Override - public boolean onPreferenceClick(Preference preference) { - if (preference.getKey().equals(getString(R.string.preference_key_account_sign_out))) { - tearDownAccount(); - } - - if (preference.getKey().equals(getString(R.string.preference_key_about_version))) { - setUpApplicationVersion(); - } - - if (preference.getKey().equals(getString(R.string.preference_key_about_feedback))) { - setUpApplicationFeedback(); - } - - if (preference.getKey().equals(getString(R.string.preference_key_about_rating))) { - setUpApplicationRating(); - } - - if (preference.getKey().equals(getString(R.string.preference_key_tell_a_friend))){ - sharedIntent(); - } - - return true; - } - - private void tearDownAccount() { - if (!getAccounts().isEmpty()) { - Account account = getAccounts().get(0); - - getAccountManager().removeAccount(account, this, null); - } else { - tearDownActivity(); - } - } - - private List getAccounts() { - return Arrays.asList(getAccountManager().getAccountsByType(AmahiAccount.TYPE)); - } - - private AccountManager getAccountManager() { - return AccountManager.get(getActivity()); - } - - @Override - public void run(AccountManagerFuture accountManagerFuture) { - tearDownActivity(); - } - - private void tearDownActivity() { - Toast.makeText(getActivity(), R.string.message_logout, Toast.LENGTH_SHORT).show(); - Intent myIntent = new Intent(getActivity().getApplicationContext(), NavigationActivity.class); - myIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(myIntent); - getActivity().finish(); - } - - private void sharedIntent(){ - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_SUBJECT,getString(R.string.share_subject)); - sendIntent.putExtra(Intent.EXTRA_TEXT,getString(R.string.share_message)); - sendIntent.setType("text/plain"); - startActivity(Intent.createChooser(sendIntent,getString(R.string.share_screen_title))); - } - - private void setUpApplicationVersion() { - Intent intent = Intents.Builder.with(getActivity()).buildVersionIntent(getActivity()); - startActivity(intent); - } - - private void setUpApplicationFeedback() { - Intent intent = Intents.Builder.with(getActivity()).buildFeedbackIntent(); - if (intent.resolveActivity(getActivity().getPackageManager()) != null) { - startActivity(intent); - } - else { - Snackbar.make(getView(),getString(R.string.application_not_found),Snackbar.LENGTH_SHORT).show(); - } - } - - private void setUpApplicationRating() { - Intent intent = Intents.Builder.with(getActivity()).buildGooglePlayIntent(); - if (intent.resolveActivity(getActivity().getPackageManager()) != null) { - startActivity(intent); - } - else { - Snackbar.make(getView(),getString(R.string.application_not_found),Snackbar.LENGTH_SHORT).show(); - } - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (key.equals(getString(R.string.preference_key_server_connection))) { - setUpSettingsSummary(); - - setUpServerConnection(); - } - } - - private void setUpServerConnection() { - if (!serverClient.isConnected()) { - return; - } - - switch (getServerConnection()) { - case AUTO: - serverClient.connectAuto(); - break; - - case LOCAL: - serverClient.connectLocal(); - break; - - case REMOTE: - serverClient.connectRemote(); - break; - - default: - break; - } - } - - private ApiConnection getServerConnection() { - ListPreference serverConnection = (ListPreference) getPreference(R.string.preference_key_server_connection); - - if (serverConnection.getValue().equals(getString(R.string.preference_key_server_connection_auto))) { - return ApiConnection.AUTO; - } - - if (serverConnection.getValue().equals(getString(R.string.preference_key_server_connection_local))) { - return ApiConnection.LOCAL; - } - - if (serverConnection.getValue().equals(getString(R.string.preference_key_server_connection_remote))) { - return ApiConnection.REMOTE; - } - - return ApiConnection.AUTO; - } - - @Override - public void onResume() { - super.onResume(); - - setUpSettingsPreferenceListener(); - } - - private void setUpSettingsPreferenceListener() { - getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener(this); - } - - @Override - public void onPause() { - super.onPause(); - - tearDownSettingsPreferenceListener(); - } - - private void tearDownSettingsPreferenceListener() { - getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); - } + SharedPreferences.OnSharedPreferenceChangeListener, + AccountManagerCallback { + @Inject + ServerClient serverClient; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setUpInjections(); + setUpSettings(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setUpTitle() { + getActivity().setTitle(R.string.title_settings); + } + + private void setUpSettings() { + setUpSettingsContent(); + setUpSettingsSummary(); + setUpSettingsListeners(); + } + + private void setUpSettingsContent() { + addPreferencesFromResource(R.xml.settings); + } + + private void setUpSettingsSummary() { + ListPreference serverConnection = (ListPreference) getPreference(R.string.preference_key_server_connection); + Preference applicationVersion = getPreference(R.string.preference_key_about_version); + Preference autoUpload = getPreference(R.string.preference_screen_key_upload); + + serverConnection.setSummary(getServerConnectionSummary()); + applicationVersion.setSummary(getApplicationVersionSummary()); + autoUpload.setSummary(getAutoUploadSummary()); + } + + private String getServerConnectionSummary() { + ListPreference serverConnection = (ListPreference) getPreference(R.string.preference_key_server_connection); + + return String.format("%s", serverConnection.getEntry()); + } + + private Preference getPreference(int id) { + return findPreference(getString(id)); + } + + private String getApplicationVersionSummary() { + return String.format( + "Amahi for Android %s\nwww.amahi.org/android", + Android.getApplicationVersion()); + } + + private String getAutoUploadSummary() { + return isUploadEnabled() ? "Enabled" : "Disabled"; + } + + private boolean isUploadEnabled() { + PreferenceManager preferenceManager = getPreferenceManager(); + return preferenceManager.getSharedPreferences() + .getBoolean(getString(R.string.preference_key_upload_switch), false); + } + + private void setUpSettingsListeners() { + Preference accountSignOut = getPreference(R.string.preference_key_account_sign_out); + Preference applicationVersion = getPreference(R.string.preference_key_about_version); + Preference applicationFeedback = getPreference(R.string.preference_key_about_feedback); + Preference applicationRating = getPreference(R.string.preference_key_about_rating); + Preference shareApp = getPreference(R.string.preference_key_tell_a_friend); + Preference autoUpload = getPreference(R.string.preference_screen_key_upload); + + accountSignOut.setOnPreferenceClickListener(this); + applicationVersion.setOnPreferenceClickListener(this); + applicationFeedback.setOnPreferenceClickListener(this); + applicationRating.setOnPreferenceClickListener(this); + shareApp.setOnPreferenceClickListener(this); + autoUpload.setOnPreferenceClickListener(this); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + if (preference.getKey().equals(getString(R.string.preference_key_account_sign_out))) { + tearDownAccount(); + } else if (preference.getKey().equals(getString(R.string.preference_key_about_version))) { + setUpApplicationVersion(); + } else if (preference.getKey().equals(getString(R.string.preference_key_about_feedback))) { + setUpApplicationFeedback(); + } else if (preference.getKey().equals(getString(R.string.preference_key_about_rating))) { + setUpApplicationRating(); + } else if (preference.getKey().equals(getString(R.string.preference_key_tell_a_friend))) { + sharedIntent(); + } else if (preference.getKey().equals(getString(R.string.preference_screen_key_upload))) { + openUploadSettingsFragment(); + } + return true; + } + + private void openUploadSettingsFragment() { + BusProvider.getBus().post(new UploadSettingsOpeningEvent()); + } + + private void tearDownAccount() { + if (!getAccounts().isEmpty()) { + Account account = getAccounts().get(0); + + getAccountManager().removeAccount(account, this, null); + } else { + tearDownActivity(); + } + } + + private List getAccounts() { + return Arrays.asList(getAccountManager().getAccountsByType(AmahiAccount.TYPE)); + } + + private AccountManager getAccountManager() { + return AccountManager.get(getActivity()); + } + + @Override + public void run(AccountManagerFuture accountManagerFuture) { + tearDownActivity(); + } + + private void tearDownActivity() { + Toast.makeText(getActivity(), R.string.message_logout, Toast.LENGTH_SHORT).show(); + Intent myIntent = new Intent(getActivity().getApplicationContext(), NavigationActivity.class); + myIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(myIntent); + getActivity().finish(); + } + + private void sharedIntent() { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_subject)); + sendIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.share_message)); + sendIntent.setType("text/plain"); + startActivity(Intent.createChooser(sendIntent, getString(R.string.share_screen_title))); + } + + private void setUpApplicationVersion() { + Intent intent = Intents.Builder.with(getActivity()).buildVersionIntent(getActivity()); + startActivity(intent); + } + + private void setUpApplicationFeedback() { + Intent intent = Intents.Builder.with(getActivity()).buildFeedbackIntent(); + if (intent.resolveActivity(getActivity().getPackageManager()) != null) { + startActivity(intent); + } else { + Snackbar.make(getView(), getString(R.string.application_not_found), Snackbar.LENGTH_SHORT).show(); + } + } + + private void setUpApplicationRating() { + Intent intent = Intents.Builder.with(getActivity()).buildGooglePlayIntent(); + if (intent.resolveActivity(getActivity().getPackageManager()) != null) { + startActivity(intent); + } else { + Snackbar.make(getView(), getString(R.string.application_not_found), Snackbar.LENGTH_SHORT).show(); + } + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(getString(R.string.preference_key_server_connection))) { + setUpSettingsSummary(); + + setUpServerConnection(); + } + } + + private void setUpServerConnection() { + if (!serverClient.isConnected()) { + return; + } + + switch (getServerConnection()) { + case AUTO: + serverClient.connectAuto(); + break; + + case LOCAL: + serverClient.connectLocal(); + break; + + case REMOTE: + serverClient.connectRemote(); + break; + + default: + break; + } + } + + private ApiConnection getServerConnection() { + ListPreference serverConnection = (ListPreference) getPreference(R.string.preference_key_server_connection); + + if (serverConnection.getValue().equals(getString(R.string.preference_key_server_connection_auto))) { + return ApiConnection.AUTO; + } + + if (serverConnection.getValue().equals(getString(R.string.preference_key_server_connection_local))) { + return ApiConnection.LOCAL; + } + + if (serverConnection.getValue().equals(getString(R.string.preference_key_server_connection_remote))) { + return ApiConnection.REMOTE; + } + + return ApiConnection.AUTO; + } + + @Override + public void onResume() { + super.onResume(); + + setUpSettingsPreferenceListener(); + setUpTitle(); + } + + private void setUpSettingsPreferenceListener() { + getPreferenceScreen().getSharedPreferences().registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + super.onPause(); + + tearDownSettingsPreferenceListener(); + } + + private void tearDownSettingsPreferenceListener() { + getPreferenceScreen().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); + } } diff --git a/src/main/java/org/amahi/anywhere/fragment/UploadBottomSheet.java b/src/main/java/org/amahi/anywhere/fragment/UploadBottomSheet.java new file mode 100644 index 000000000..395f43be1 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/fragment/UploadBottomSheet.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.fragment; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.BottomSheetBehavior; +import android.support.design.widget.BottomSheetDialog; +import android.support.design.widget.BottomSheetDialogFragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.AdapterView; +import android.widget.FrameLayout; +import android.widget.ListView; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.adapter.UploadOptionsAdapter; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.UploadClickEvent; +import org.amahi.anywhere.model.UploadOption; + +import java.util.ArrayList; + +/** + * Bottom sheet component for showing upload related options. + * Extends {@link android.support.design.widget.BottomSheetDialog} + */ +public class UploadBottomSheet extends BottomSheetDialogFragment implements AdapterView.OnItemClickListener { + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.upload_bottom_sheet, container); + rootView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) getDialog(); + FrameLayout bottomSheet = (FrameLayout) bottomSheetDialog.findViewById(android.support.design.R.id.design_bottom_sheet); + assert bottomSheet != null; + BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheet); + behavior.setState(BottomSheetBehavior.STATE_EXPANDED); + behavior.setPeekHeight(0); + } + }); + return rootView; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + setUpListView(view); + } + + private ArrayList getListItems() { + ArrayList uploadOptions = new ArrayList<>(); + + uploadOptions.add(new UploadOption(UploadOption.CAMERA, + getString(R.string.upload_camera), + R.drawable.ic_camera)); + + uploadOptions.add(new UploadOption(UploadOption.FILE, + getString(R.string.upload_photo), + R.drawable.ic_cloud_upload)); + + return uploadOptions; + } + + private void setUpListView(View view) { + UploadOptionsAdapter adapter = new UploadOptionsAdapter(getContext(), getListItems()); + ListView listView = (ListView) view.findViewById(R.id.upload_options_list); + assert listView != null; + listView.setAdapter(adapter); + listView.setOnItemClickListener(this); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + UploadOption uploadOption = getListItems().get(position); + BusProvider.getBus().post(new UploadClickEvent(uploadOption.getType())); + dismiss(); + } +} diff --git a/src/main/java/org/amahi/anywhere/fragment/UploadSettingsFragment.java b/src/main/java/org/amahi/anywhere/fragment/UploadSettingsFragment.java new file mode 100644 index 000000000..e9b63d250 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/fragment/UploadSettingsFragment.java @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2015 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.fragment; + +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.preference.SwitchPreference; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.design.widget.Snackbar; +import android.view.View; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.account.AmahiAccount; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerConnectedEvent; +import org.amahi.anywhere.bus.ServerConnectionChangedEvent; +import org.amahi.anywhere.bus.ServerSharesLoadedEvent; +import org.amahi.anywhere.bus.ServersLoadedEvent; +import org.amahi.anywhere.server.client.AmahiClient; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.server.model.ServerShare; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.inject.Inject; + +import pub.devrel.easypermissions.AppSettingsDialog; +import pub.devrel.easypermissions.EasyPermissions; + +/** + * Upload Settings fragment. Shows upload settings. + */ +public class UploadSettingsFragment extends PreferenceFragment implements + Preference.OnPreferenceChangeListener, + AccountManagerCallback, + EasyPermissions.PermissionCallbacks { + + private static final int READ_PERMISSIONS = 110; + @Inject + AmahiClient amahiClient; + + @Inject + ServerClient serverClient; + + private String authenticationToken; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setUpInjections(); + + setUpTitle(); + + setUpSettings(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setUpTitle() { + getActivity().setTitle(R.string.preference_title_upload_settings); + } + + private void setUpSettings() { + setUpSettingsContent(); + setUpSettingsTitle(); + toggleUploadSettings(isUploadEnabled()); + setUpSettingsListeners(); + } + + private void setUpSettingsContent() { + addPreferencesFromResource(R.xml.upload_settings); + } + + private void setUpSettingsTitle() { + getAutoUploadSwitchPreference().setTitle(getAutoUploadTitle(isUploadEnabled())); + } + + private AccountManager getAccountManager() { + return AccountManager.get(getActivity()); + } + + private List getAccounts() { + return Arrays.asList(getAccountManager().getAccountsByType(AmahiAccount.TYPE)); + } + + private void setUpAuthenticationToken() { + if (authenticationToken != null) { + setUpServersContent(authenticationToken); + } else { + Account account = getAccounts().get(0); + getAccountManager().getAuthToken(account, AmahiAccount.TYPE, null, getActivity(), this, null); + } + + } + + @Override + public void run(AccountManagerFuture future) { + try { + Bundle accountManagerResult = future.getResult(); + + authenticationToken = accountManagerResult.getString(AccountManager.KEY_AUTHTOKEN); + + setUpAuthenticationToken(); + + } catch (OperationCanceledException e) { + tearDownActivity(); + } catch (IOException | AuthenticatorException e) { + throw new RuntimeException(e); + } + } + + private void setUpServersContent(String authenticationToken) { + amahiClient.getServers(getActivity(), authenticationToken); + } + + @Subscribe + public void onServersLoaded(ServersLoadedEvent event) { + setUpServersContent(event.getServers()); + } + + private void setUpServersContent(List servers) { + ArrayList activeServers = filterActiveServers(servers); + String[] serverNames = new String[activeServers.size()]; + String[] serverSessions = new String[activeServers.size()]; + + for (int i = 0; i < activeServers.size(); i++) { + Server activeServer = activeServers.get(i); + serverNames[i] = activeServer.getName(); + serverSessions[i] = activeServer.getSession(); + } + + getHdaPreference().setEntries(serverNames); + getHdaPreference().setEntryValues(serverSessions); + getHdaPreference().setEnabled(true); + + String session = getHdaPreference().getValue(); + if (session != null) { + setUpServer(session); + } + } + + private ArrayList filterActiveServers(List servers) { + ArrayList activeServers = new ArrayList<>(); + + for (Server server : servers) { + if (server.isActive()) { + activeServers.add(server); + } + } + + return activeServers; + } + + private boolean isUploadEnabled() { + PreferenceManager preferenceManager = getPreferenceManager(); + return preferenceManager.getSharedPreferences() + .getBoolean(getString(R.string.preference_key_upload_switch), false); + } + + private String getAutoUploadTitle(boolean isUploadEnabled) { + return isUploadEnabled ? "Disable" : "Enable"; + } + + private void setUpSettingsListeners() { + getAutoUploadSwitchPreference().setOnPreferenceChangeListener(this); + getHdaPreference().setOnPreferenceChangeListener(this); + getSharePreference().setOnPreferenceChangeListener(this); + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String key = preference.getKey(); + if (key.equals(getString(R.string.preference_key_upload_switch))) { + boolean isUploadEnabled = (boolean) newValue; + if (isUploadEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + checkReadPermissions(); + return false; + } + } + toggleUploadSettings(isUploadEnabled); + preference.setTitle(getAutoUploadTitle(isUploadEnabled)); + } else if (key.equals(getString(R.string.preference_key_upload_hda))) { + setUpServer(String.valueOf(newValue)); + } else if (key.equals(getString(R.string.preference_key_upload_share))) { + getPathPreference().setEnabled(true); + getAllowOnDataPreference().setEnabled(true); + } + return true; + } + + private void setUpServer(String session) { + getSharePreference().setEnabled(false); + getPathPreference().setEnabled(false); + getAllowOnDataPreference().setEnabled(false); + + Server server = new Server(session); + setUpServerConnection(server); + } + + @Subscribe + public void onServerConnected(ServerConnectedEvent event) { + setUpServerConnection(); + } + + private void setUpServerConnection(Server server) { + if (serverClient.isConnected(server)) { + setUpServerConnection(); + } else { + serverClient.connect(getActivity(), server); + } + } + + private void setUpServerConnection() { + if (!isConnectionAvailable() || isConnectionAuto()) { + serverClient.connectAuto(); + return; + } + + if (isConnectionLocal()) { + serverClient.connectLocal(); + } else { + serverClient.connectRemote(); + } + } + + + @Subscribe + public void onServerConnectionChanged(ServerConnectionChangedEvent event) { + setUpSharesContent(); + } + + private void setUpSharesContent() { + if (serverClient.isConnected()) { + serverClient.getShares(); + } + } + + @Subscribe + public void onSharesLoaded(ServerSharesLoadedEvent event) { + setUpSharesContent(event.getServerShares()); + } + + private void setUpSharesContent(List shares) { + String[] shareNames = new String[shares.size()]; + for (int i = 0; i < shares.size(); i++) { + shareNames[i] = shares.get(i).getName(); + } + getSharePreference().setEntries(shareNames); + getSharePreference().setEntryValues(shareNames); + getSharePreference().setEnabled(true); + + String selectedShare = getSharePreference().getValue(); + if (selectedShare != null) { + getPathPreference().setEnabled(true); + getAllowOnDataPreference().setEnabled(true); + } + } + + private boolean isConnectionAvailable() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + + return preferences.contains(getString(R.string.preference_key_server_connection)); + } + + private boolean isConnectionAuto() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); + + return preferenceConnection.equals(getString(R.string.preference_key_server_connection_auto)); + } + + private boolean isConnectionLocal() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); + + return preferenceConnection.equals(getString(R.string.preference_key_server_connection_local)); + } + + private void toggleUploadSettings(boolean isUploadEnabled) { + if (isUploadEnabled) { + setUpAuthenticationToken(); + } else { + getHdaPreference().setEnabled(false); + getSharePreference().setEnabled(false); + getPathPreference().setEnabled(false); + getAllowOnDataPreference().setEnabled(false); + } + } + + private Preference getPreference(int id) { + return findPreference(getString(id)); + } + + private SwitchPreference getAutoUploadSwitchPreference() { + return (SwitchPreference) getPreference(R.string.preference_key_upload_switch); + } + + private ListPreference getHdaPreference() { + return (ListPreference) getPreference(R.string.preference_key_upload_hda); + } + + private ListPreference getSharePreference() { + return (ListPreference) getPreference(R.string.preference_key_upload_share); + } + + private EditTextPreference getPathPreference() { + return (EditTextPreference) getPreference(R.string.preference_key_upload_path); + } + + private SwitchPreference getAllowOnDataPreference() { + return (SwitchPreference) getPreference(R.string.preference_key_upload_data); + } + + private void tearDownActivity() { + getActivity().finish(); + } + + + @Override + public void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + // Forward results to EasyPermissions + EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this); + } + + @Override + public void onPermissionsGranted(int requestCode, List perms) { + if (requestCode == READ_PERMISSIONS) { + toggleUploadSettings(true); + getAutoUploadSwitchPreference().setTitle(getAutoUploadTitle(true)); + } + } + + @Override + public void onPermissionsDenied(int requestCode, List perms) { + if (requestCode == READ_PERMISSIONS) { + showPermissionSnackBar(getString(R.string.file_upload_permission_denied)); + } + } + + private void showPermissionSnackBar(String message) { + Snackbar.make(getView(), message, Snackbar.LENGTH_LONG) + .setAction(R.string.menu_settings, new View.OnClickListener() { + @Override + public void onClick(View v) { + new AppSettingsDialog.Builder(UploadSettingsFragment.this).build().show(); + } + }) + .show(); + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private void checkReadPermissions() { + String[] perms = {Manifest.permission.READ_EXTERNAL_STORAGE}; + if (!EasyPermissions.hasPermissions(getContext(), perms)) { + EasyPermissions.requestPermissions(this, getString(R.string.file_upload_permission), + READ_PERMISSIONS, perms); + } + } +} diff --git a/src/main/java/org/amahi/anywhere/job/NetConnectivityJob.java b/src/main/java/org/amahi/anywhere/job/NetConnectivityJob.java new file mode 100644 index 000000000..371c707e5 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/job/NetConnectivityJob.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.job; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.support.annotation.RequiresApi; +import android.util.Log; + +import org.amahi.anywhere.AmahiApplication.JobIds; +import org.amahi.anywhere.service.UploadService; + +/** + * Job to monitor when there is a change to photos in the media provider. + */ +@RequiresApi(api = Build.VERSION_CODES.N) +public class NetConnectivityJob extends JobService { + // A pre-built JobInfo we use for scheduling our job. + static final JobInfo JOB_INFO; + + static { + JobInfo.Builder builder = new JobInfo.Builder(JobIds.NET_CONNECTIVITY_JOB, + new ComponentName("org.amahi.anywhere", NetConnectivityJob.class.getName())); + builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); + JOB_INFO = builder.build(); + } + + private final String TAG = this.getClass().getName(); + + // Schedule this job, replace any existing one. + public static void scheduleJob(Context context) { + JobScheduler js = context.getSystemService(JobScheduler.class); + js.schedule(JOB_INFO); + Log.i("NetworkConnectivityJob", "JOB SCHEDULED!"); + } + + // Check whether this job is currently scheduled. + public static boolean isScheduled(Context context) { + JobScheduler js = context.getSystemService(JobScheduler.class); + JobInfo job = js.getPendingJob(JobIds.NET_CONNECTIVITY_JOB); + return job != null; + } + + // Cancel this job, if currently scheduled. + public static void cancelJob(Context context) { + JobScheduler js = context.getSystemService(JobScheduler.class); + js.cancel(JobIds.NET_CONNECTIVITY_JOB); + } + + @Override + public boolean onStartJob(JobParameters params) { + Log.i(TAG, "JOB STARTED!"); + Intent intent = new Intent(this, UploadService.class); + startService(intent); + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } +} diff --git a/src/main/java/org/amahi/anywhere/job/PhotosContentJob.java b/src/main/java/org/amahi/anywhere/job/PhotosContentJob.java new file mode 100644 index 000000000..395152c0f --- /dev/null +++ b/src/main/java/org/amahi/anywhere/job/PhotosContentJob.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.job; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import android.support.annotation.RequiresApi; +import android.util.Log; + +import org.amahi.anywhere.AmahiApplication.JobIds; +import org.amahi.anywhere.util.Intents; + +import java.util.ArrayList; +import java.util.List; + +/** + * Job to monitor when there is a change to photos in the media provider. + */ +@RequiresApi(api = Build.VERSION_CODES.N) +public class PhotosContentJob extends JobService { + // The root URI of the media provider, to monitor for generic changes to its content. + static final Uri MEDIA_URI = Uri.parse("content://" + MediaStore.AUTHORITY + "/"); + // Path segments for image-specific URIs in the provider. + static final List EXTERNAL_PATH_SEGMENTS + = MediaStore.Images.Media.EXTERNAL_CONTENT_URI.getPathSegments(); + // A pre-built JobInfo we use for scheduling our job. + static final JobInfo JOB_INFO; + + static { + JobInfo.Builder builder = new JobInfo.Builder(JobIds.PHOTOS_CONTENT_JOB, + new ComponentName("org.amahi.anywhere", PhotosContentJob.class.getName())); + // Look for specific changes to images in the provider. + builder.addTriggerContentUri(new JobInfo.TriggerContentUri( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS)); + // Also look for general reports of changes in the overall provider. + builder.addTriggerContentUri(new JobInfo.TriggerContentUri(MEDIA_URI, 0)); + JOB_INFO = builder.build(); + } + + private final String TAG = this.getClass().getName(); + JobParameters mRunningParams; + + // Schedule this job, replace any existing one. + public static void scheduleJob(Context context) { + JobScheduler js = context.getSystemService(JobScheduler.class); + js.schedule(JOB_INFO); + Log.i("PhotosContentJob", "JOB SCHEDULED!"); + } + + // Check whether this job is currently scheduled. + public static boolean isScheduled(Context context) { + JobScheduler js = context.getSystemService(JobScheduler.class); + JobInfo job = js.getPendingJob(JobIds.PHOTOS_CONTENT_JOB); + return job != null; + } + + // Cancel this job, if currently scheduled. + public static void cancelJob(Context context) { + JobScheduler js = context.getSystemService(JobScheduler.class); + js.cancel(JobIds.PHOTOS_CONTENT_JOB); + } + + @Override + public boolean onStartJob(JobParameters params) { + Log.i("PhotosContentJob", "JOB STARTED!"); + mRunningParams = params; + + // Did we trigger due to a content change? + if (params.getTriggeredContentAuthorities() != null) { + if (params.getTriggeredContentUris() != null) { + // If we have details about which URIs changed, then iterate through them + // and collect valid uris and send them to UploadService + ArrayList uris = new ArrayList<>(); + for (Uri uri : params.getTriggeredContentUris()) { + List path = uri.getPathSegments(); + if (path != null && path.size() == EXTERNAL_PATH_SEGMENTS.size() + 1) { + // This is a specific file. + uris.add(uri); + } + } + Intent intent = Intents.Builder.with(this).buildUploadServiceIntent(uris); + startService(intent); + } else { + // We don't have any details about URIs (because too many changed at once) + Log.i(TAG, "Photos rescan needed!"); + } + } else { + Log.i(TAG, "(No photos content)"); + } + + scheduleJob(PhotosContentJob.this); + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } +} diff --git a/src/main/java/org/amahi/anywhere/model/UploadFile.java b/src/main/java/org/amahi/anywhere/model/UploadFile.java new file mode 100644 index 000000000..caf607514 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/model/UploadFile.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.model; + +public class UploadFile { + private int id; + private String path; + + public UploadFile(int id, String path) { + this.id = id; + this.path = path; + } + + public int getId() { + return id; + } + + public String getPath() { + return path; + } +} diff --git a/src/main/java/org/amahi/anywhere/model/UploadOption.java b/src/main/java/org/amahi/anywhere/model/UploadOption.java new file mode 100644 index 000000000..f4bbc92b7 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/model/UploadOption.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.model; + +import android.support.annotation.IntDef; + +import org.amahi.anywhere.fragment.UploadBottomSheet; + +/** + * Upload option model for display in {@link UploadBottomSheet} + */ +public class UploadOption { + public static final int CAMERA = 1; + public static final int FILE = 2; + @Types + private int type; + private String name; + private int icon; + public UploadOption(@Types int type, String name, int icon) { + this.name = name; + this.icon = icon; + this.type = type; + } + + @Types + public int getType() { + return type; + } + + public String getName() { + return name; + } + + public int getIcon() { + return icon; + } + + @IntDef({CAMERA, FILE}) + public @interface Types { + } +} diff --git a/src/main/java/org/amahi/anywhere/receiver/AudioReceiver.java b/src/main/java/org/amahi/anywhere/receiver/AudioReceiver.java index 785dcfce9..58a357f81 100644 --- a/src/main/java/org/amahi/anywhere/receiver/AudioReceiver.java +++ b/src/main/java/org/amahi/anywhere/receiver/AudioReceiver.java @@ -36,63 +36,62 @@ * Audio system events receiver. Proxies system audio events such as changing track * or audio focus to the local {@link com.squareup.otto.Bus} as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class AudioReceiver extends BroadcastReceiver -{ - @Override - public void onReceive(Context context, Intent intent) { - if (intent == null) { - return; - } - - String action = intent.getAction(); - - if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(action)) { - handleAudioChangeEvent(); - } - - if (Intent.ACTION_MEDIA_BUTTON.equals(action)) { - handleAudioControlEvent(intent); - } - } - - private void handleAudioChangeEvent() { - BusProvider.getBus().post(new AudioControlPauseEvent()); - } - - private void handleAudioControlEvent(Intent intent) { - KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); - - if (event == null) { - return; - } - - if (event.getAction() != KeyEvent.ACTION_DOWN) { - return; - } - - switch (event.getKeyCode()) { - case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - BusProvider.getBus().post(new AudioControlPlayPauseEvent()); - break; - - case KeyEvent.KEYCODE_MEDIA_PLAY: - BusProvider.getBus().post(new AudioControlPlayEvent()); - break; - - case KeyEvent.KEYCODE_MEDIA_PAUSE: - BusProvider.getBus().post(new AudioControlPauseEvent()); - break; - - case KeyEvent.KEYCODE_MEDIA_NEXT: - BusProvider.getBus().post(new AudioControlNextEvent()); - break; - - case KeyEvent.KEYCODE_MEDIA_PREVIOUS: - BusProvider.getBus().post(new AudioControlPreviousEvent()); - break; - - default: - break; - } - } +public class AudioReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + + String action = intent.getAction(); + + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(action)) { + handleAudioChangeEvent(); + } + + if (Intent.ACTION_MEDIA_BUTTON.equals(action)) { + handleAudioControlEvent(intent); + } + } + + private void handleAudioChangeEvent() { + BusProvider.getBus().post(new AudioControlPauseEvent()); + } + + private void handleAudioControlEvent(Intent intent) { + KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); + + if (event == null) { + return; + } + + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return; + } + + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + BusProvider.getBus().post(new AudioControlPlayPauseEvent()); + break; + + case KeyEvent.KEYCODE_MEDIA_PLAY: + BusProvider.getBus().post(new AudioControlPlayEvent()); + break; + + case KeyEvent.KEYCODE_MEDIA_PAUSE: + BusProvider.getBus().post(new AudioControlPauseEvent()); + break; + + case KeyEvent.KEYCODE_MEDIA_NEXT: + BusProvider.getBus().post(new AudioControlNextEvent()); + break; + + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + BusProvider.getBus().post(new AudioControlPreviousEvent()); + break; + + default: + break; + } + } } diff --git a/src/main/java/org/amahi/anywhere/receiver/CameraReceiver.java b/src/main/java/org/amahi/anywhere/receiver/CameraReceiver.java new file mode 100644 index 000000000..adec0a32a --- /dev/null +++ b/src/main/java/org/amahi/anywhere/receiver/CameraReceiver.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.amahi.anywhere.util.Intents; + +/** + * Camera new picture event receiver. + */ +public class CameraReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Log.e("NEW_IMAGE", "onReceive"); + Intent uploadService = Intents.Builder.with(context).buildUploadServiceIntent(intent.getData()); + context.startService(uploadService); + } +} diff --git a/src/main/java/org/amahi/anywhere/receiver/NetworkReceiver.java b/src/main/java/org/amahi/anywhere/receiver/NetworkReceiver.java index 5c4d7489b..2ba39adaf 100644 --- a/src/main/java/org/amahi/anywhere/receiver/NetworkReceiver.java +++ b/src/main/java/org/amahi/anywhere/receiver/NetworkReceiver.java @@ -27,37 +27,44 @@ import org.amahi.anywhere.bus.BusProvider; import org.amahi.anywhere.bus.NetworkChangedEvent; +import org.amahi.anywhere.service.UploadService; +import org.amahi.anywhere.util.NetworkUtils; /** * Network system events receiver. Proxies system network events such as changing network connection * to the local {@link com.squareup.otto.Bus} as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class NetworkReceiver extends BroadcastReceiver -{ - @Override - public void onReceive(Context context, Intent intent) { - if (intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { - handleNetworkChangeEvent(context); - } - } - - private void handleNetworkChangeEvent(Context context) { - NetworkInfo network = getNetwork(context); - - if (isNetworkConnected(network)) { - BusProvider.getBus().post(new NetworkChangedEvent(network.getType())); - } - } - - private NetworkInfo getNetwork(Context context) { - return getNetworkManager(context).getActiveNetworkInfo(); - } - - private ConnectivityManager getNetworkManager(Context context) { - return (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - } - - private boolean isNetworkConnected(NetworkInfo network) { - return (network != null) && network.isConnected(); - } +public class NetworkReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { + handleNetworkChangeEvent(context); + } + } + + private void handleNetworkChangeEvent(Context context) { + NetworkUtils networkUtils = new NetworkUtils(context); + NetworkInfo network = networkUtils.getNetwork(); + if (networkUtils.isNetworkConnected(network)) { + BusProvider.getBus().post(new NetworkChangedEvent(network.getType())); + } + + if (networkUtils.isUploadAllowed()) { + startUploadService(context); + } else { + stopUploadService(context); + } + } + + private void startUploadService(Context context) { + Intent uploadService = new Intent(context, UploadService.class); + context.startService(uploadService); + } + + private void stopUploadService(Context context) { + Intent uploadService = new Intent(context, UploadService.class); + context.stopService(uploadService); + } + + } diff --git a/src/main/java/org/amahi/anywhere/server/ApiAdapter.java b/src/main/java/org/amahi/anywhere/server/ApiAdapter.java index 2e22140d2..2ef308504 100644 --- a/src/main/java/org/amahi/anywhere/server/ApiAdapter.java +++ b/src/main/java/org/amahi/anywhere/server/ApiAdapter.java @@ -32,22 +32,21 @@ * dependency injection provided components. */ @Singleton -public class ApiAdapter -{ - private final Retrofit.Builder apiBuilder; - - @Inject - public ApiAdapter(OkHttpClient client, Factory factory) { - this.apiBuilder = buildApiBuilder(client, factory); - } - - private Retrofit.Builder buildApiBuilder(OkHttpClient client, Factory factory) { - return new Retrofit.Builder() - .client(client) - .addConverterFactory(factory); - } - - public T create(Class api, String apiUrl) { - return apiBuilder.baseUrl(apiUrl).build().create(api); - } +public class ApiAdapter { + private final Retrofit.Builder apiBuilder; + + @Inject + public ApiAdapter(OkHttpClient client, Factory factory) { + this.apiBuilder = buildApiBuilder(client, factory); + } + + private Retrofit.Builder buildApiBuilder(OkHttpClient client, Factory factory) { + return new Retrofit.Builder() + .client(client) + .addConverterFactory(factory); + } + + public T create(Class api, String apiUrl) { + return apiBuilder.baseUrl(apiUrl).build().create(api); + } } diff --git a/src/main/java/org/amahi/anywhere/server/ApiConnection.java b/src/main/java/org/amahi/anywhere/server/ApiConnection.java index 7ca3f9bdd..918e9e253 100644 --- a/src/main/java/org/amahi/anywhere/server/ApiConnection.java +++ b/src/main/java/org/amahi/anywhere/server/ApiConnection.java @@ -19,9 +19,8 @@ package org.amahi.anywhere.server; -public enum ApiConnection -{ - AUTO, - LOCAL, - REMOTE +public enum ApiConnection { + AUTO, + LOCAL, + REMOTE } diff --git a/src/main/java/org/amahi/anywhere/server/ApiConnectionDetector.java b/src/main/java/org/amahi/anywhere/server/ApiConnectionDetector.java index bf8c02e52..4256de27c 100644 --- a/src/main/java/org/amahi/anywhere/server/ApiConnectionDetector.java +++ b/src/main/java/org/amahi/anywhere/server/ApiConnectionDetector.java @@ -36,54 +36,52 @@ * API connection guesser. Tries to connect to the server address to determine if it is available * and returns it if succeed or another one otherwise. */ -public class ApiConnectionDetector -{ - private static final class Connection - { - private Connection() { - } +public class ApiConnectionDetector { + private OkHttpClient httpClient; - static final int TIMEOUT = 1; - } + public ApiConnectionDetector() { + this.httpClient = buildHttpClient(); + } - private OkHttpClient httpClient; + private OkHttpClient buildHttpClient() { + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder(); + clientBuilder.connectTimeout(Connection.TIMEOUT, TimeUnit.SECONDS); + httpClient = clientBuilder.build(); + return httpClient; + } - public ApiConnectionDetector() { - this.httpClient = buildHttpClient(); - } + public String detect(ServerRoute serverRoute) { + Timber.tag("CONNECTION"); - private OkHttpClient buildHttpClient() { - OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder(); - clientBuilder.connectTimeout(Connection.TIMEOUT, TimeUnit.SECONDS); - httpClient = clientBuilder.build(); - return httpClient; - } + try { + Request httpRequest = new Request.Builder() + .url(getConnectionUrl(serverRoute.getLocalAddress())) + .build(); - public String detect(ServerRoute serverRoute) { - Timber.tag("CONNECTION"); + Response httpResponse = httpClient + .newCall(httpRequest) + .execute(); - try { - Request httpRequest = new Request.Builder() - .url(getConnectionUrl(serverRoute.getLocalAddress())) - .build(); + httpResponse.body().close(); - Response httpResponse = httpClient - .newCall(httpRequest) - .execute(); + Timber.d("Using local address."); - httpResponse.body().close(); + return serverRoute.getLocalAddress(); + } catch (IOException e) { + Timber.d("Using remote address."); - Timber.d("Using local address."); + return serverRoute.getRemoteAddress(); + } + } - return serverRoute.getLocalAddress(); - } catch (IOException e) { - Timber.d("Using remote address."); + private URL getConnectionUrl(String serverAddress) throws IOException { + return new URL(Uri.parse(serverAddress).buildUpon().appendPath("shares").build().toString()); + } - return serverRoute.getRemoteAddress(); - } - } + private static final class Connection { + static final int TIMEOUT = 1; - private URL getConnectionUrl(String serverAddress) throws IOException { - return new URL(Uri.parse(serverAddress).buildUpon().appendPath("shares").build().toString()); - } + private Connection() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/server/ApiHeaders.java b/src/main/java/org/amahi/anywhere/server/ApiHeaders.java index 9a45f97b9..8d3d838a5 100644 --- a/src/main/java/org/amahi/anywhere/server/ApiHeaders.java +++ b/src/main/java/org/amahi/anywhere/server/ApiHeaders.java @@ -33,42 +33,38 @@ /** * API headers accessor. */ -class ApiHeaders implements Interceptor -{ - private static final class HeaderFields - { - private HeaderFields() { - } +class ApiHeaders implements Interceptor { + private final String acceptHeader; + private final String userAgentHeader; + public ApiHeaders(Context context) { + this.acceptHeader = getAcceptHeader(); + this.userAgentHeader = getUserAgentHeader(context); + } - public static final String ACCEPT = "Accept"; - public static final String USER_AGENT = "User-Agent"; - } + private String getAcceptHeader() { + return "application/json"; + } - private final String acceptHeader; - private final String userAgentHeader; + private String getUserAgentHeader(Context context) { + return Identifier.getUserAgent(context); + } - public ApiHeaders(Context context) { - this.acceptHeader = getAcceptHeader(); - this.userAgentHeader = getUserAgentHeader(context); - } + @Override + public Response intercept(Chain chain) throws IOException { + Request original = chain.request(); + Request request = original.newBuilder() + .addHeader(HeaderFields.ACCEPT, acceptHeader) + .addHeader(HeaderFields.USER_AGENT, userAgentHeader) + .method(original.method(), original.body()) + .build(); - private String getAcceptHeader() { - return "application/json"; - } + return chain.proceed(request); + } - private String getUserAgentHeader(Context context) { - return Identifier.getUserAgent(context); - } - - @Override - public Response intercept(Chain chain) throws IOException { - Request original = chain.request(); - Request request = original.newBuilder() - .addHeader(HeaderFields.ACCEPT, acceptHeader) - .addHeader(HeaderFields.USER_AGENT, userAgentHeader) - .method(original.method(), original.body()) - .build(); - - return chain.proceed(request); - } + private static final class HeaderFields { + public static final String ACCEPT = "Accept"; + public static final String USER_AGENT = "User-Agent"; + private HeaderFields() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/server/ApiModule.java b/src/main/java/org/amahi/anywhere/server/ApiModule.java index b7405e9e2..836f3eef1 100644 --- a/src/main/java/org/amahi/anywhere/server/ApiModule.java +++ b/src/main/java/org/amahi/anywhere/server/ApiModule.java @@ -40,42 +40,41 @@ * for possible consumers. */ @Module( - complete = false, - library = true + complete = false, + library = true ) -public class ApiModule -{ - @Provides - @Singleton - OkHttpClient provideHttpClient(ApiHeaders headers, HttpLoggingInterceptor logging) { - OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder(); - clientBuilder.addInterceptor(headers); - clientBuilder.addInterceptor(logging); - return clientBuilder.build(); - } +public class ApiModule { + @Provides + @Singleton + OkHttpClient provideHttpClient(ApiHeaders headers, HttpLoggingInterceptor logging) { + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder(); + clientBuilder.addInterceptor(headers); + clientBuilder.addInterceptor(logging); + return clientBuilder.build(); + } - @Provides - @Singleton - ApiHeaders provideHeaders(Context context) { - return new ApiHeaders(context); - } + @Provides + @Singleton + ApiHeaders provideHeaders(Context context) { + return new ApiHeaders(context); + } - @Provides - @Singleton - Converter.Factory provideJsonConverterFactory(Gson json) { - return GsonConverterFactory.create(json); - } + @Provides + @Singleton + Converter.Factory provideJsonConverterFactory(Gson json) { + return GsonConverterFactory.create(json); + } - @Provides - @Singleton - Gson provideJson() { - return new GsonBuilder().setDateFormat(Time.Format.RFC_1123).create(); - } + @Provides + @Singleton + Gson provideJson() { + return new GsonBuilder().setDateFormat(Time.Format.RFC_1123).create(); + } - @Provides - @Singleton - HttpLoggingInterceptor provideLogging() { - // change the level below to HttpLoggingInterceptor.Level.BODY to get the whole body in the logs - return new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.HEADERS); - } + @Provides + @Singleton + HttpLoggingInterceptor provideLogging() { + // change the level below to HttpLoggingInterceptor.Level.BODY to get the whole body in the logs + return new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.HEADERS); + } } diff --git a/src/main/java/org/amahi/anywhere/server/api/AmahiApi.java b/src/main/java/org/amahi/anywhere/server/api/AmahiApi.java index d97b01383..a7a4cd991 100644 --- a/src/main/java/org/amahi/anywhere/server/api/AmahiApi.java +++ b/src/main/java/org/amahi/anywhere/server/api/AmahiApi.java @@ -35,17 +35,16 @@ /** * Amahi API declaration. */ -public interface AmahiApi -{ - @FormUrlEncoded - @POST("/api2/oauth/token?grant_type=password") - Call authenticate( - @Field("client_id") String clientId, - @Field("client_secret") String clientSecret, - @Field("username") String username, - @Field("password") String password); +public interface AmahiApi { + @FormUrlEncoded + @POST("/api2/oauth/token?grant_type=password") + Call authenticate( + @Field("client_id") String clientId, + @Field("client_secret") String clientSecret, + @Field("username") String username, + @Field("password") String password); - @GET("/api2/servers") - Call> getServers( - @Query("access_token") String authenticationToken); + @GET("/api2/servers") + Call> getServers( + @Query("access_token") String authenticationToken); } diff --git a/src/main/java/org/amahi/anywhere/server/api/ProxyApi.java b/src/main/java/org/amahi/anywhere/server/api/ProxyApi.java index 2a6365e9a..46b4112c1 100644 --- a/src/main/java/org/amahi/anywhere/server/api/ProxyApi.java +++ b/src/main/java/org/amahi/anywhere/server/api/ProxyApi.java @@ -29,9 +29,8 @@ /** * Proxy API declaration. */ -public interface ProxyApi -{ - @PUT("/client") - Call getServerRoute( - @Header("Session") String session); +public interface ProxyApi { + @PUT("/client") + Call getServerRoute( + @Header("Session") String session); } diff --git a/src/main/java/org/amahi/anywhere/server/api/ServerApi.java b/src/main/java/org/amahi/anywhere/server/api/ServerApi.java index de1bba278..937c789f8 100644 --- a/src/main/java/org/amahi/anywhere/server/api/ServerApi.java +++ b/src/main/java/org/amahi/anywhere/server/api/ServerApi.java @@ -26,33 +26,52 @@ import java.util.List; +import okhttp3.MultipartBody; +import okhttp3.ResponseBody; import retrofit2.Call; +import retrofit2.http.DELETE; import retrofit2.http.GET; import retrofit2.http.Header; +import retrofit2.http.Multipart; +import retrofit2.http.POST; +import retrofit2.http.Part; import retrofit2.http.Query; /** * Server API declaration. */ -public interface ServerApi -{ - @GET("/shares") - Call> getShares( - @Header("Session") String session); - - @GET("/files") - Call> getFiles( - @Header("Session") String session, - @Query("s") String share, - @Query("p") String path); - - @GET("/md") - Call getFileMetadata( - @Header("Session") String session, - @Query("f") String fileName, - @Query("h") String hint); - - @GET("/apps") - Call> getApps( - @Header("Session") String session); +public interface ServerApi { + @GET("/shares") + Call> getShares( + @Header("Session") String session); + + @GET("/files") + Call> getFiles( + @Header("Session") String session, + @Query("s") String share, + @Query("p") String path); + + @DELETE("/files") + Call deleteFile( + @Header("Session") String session, + @Query("s") String share, + @Query("p") String path); + + @Multipart + @POST("/files") + Call uploadFile( + @Header("Session") String session, + @Query("s") String share, + @Query("p") String path, + @Part MultipartBody.Part file); + + @GET("/md") + Call getFileMetadata( + @Header("Session") String session, + @Query("f") String fileName, + @Query("h") String hint); + + @GET("/apps") + Call> getApps( + @Header("Session") String session); } diff --git a/src/main/java/org/amahi/anywhere/server/client/AmahiClient.java b/src/main/java/org/amahi/anywhere/server/client/AmahiClient.java index 543269379..419312354 100644 --- a/src/main/java/org/amahi/anywhere/server/client/AmahiClient.java +++ b/src/main/java/org/amahi/anywhere/server/client/AmahiClient.java @@ -19,6 +19,8 @@ package org.amahi.anywhere.server.client; +import android.content.Context; + import org.amahi.anywhere.server.Api; import org.amahi.anywhere.server.ApiAdapter; import org.amahi.anywhere.server.api.AmahiApi; @@ -32,24 +34,23 @@ * Amahi API implementation. Wraps {@link org.amahi.anywhere.server.api.AmahiApi}. */ @Singleton -public class AmahiClient -{ - private final AmahiApi api; - - @Inject - public AmahiClient(ApiAdapter apiAdapter) { - this.api = buildApi(apiAdapter); - } - - private AmahiApi buildApi(ApiAdapter apiAdapter) { - return apiAdapter.create(AmahiApi.class, Api.getAmahiUrl()); - } - - public void authenticate(String username, String password) { - api.authenticate(Api.getClientId(), Api.getClientSecret(), username, password).enqueue(new AuthenticationResponse()); - } - - public void getServers(String authenticationToken) { - api.getServers(authenticationToken).enqueue(new ServersResponse()); - } +public class AmahiClient { + private final AmahiApi api; + + @Inject + public AmahiClient(ApiAdapter apiAdapter) { + this.api = buildApi(apiAdapter); + } + + private AmahiApi buildApi(ApiAdapter apiAdapter) { + return apiAdapter.create(AmahiApi.class, Api.getAmahiUrl()); + } + + public void authenticate(String username, String password) { + api.authenticate(Api.getClientId(), Api.getClientSecret(), username, password).enqueue(new AuthenticationResponse()); + } + + public void getServers(Context context, String authenticationToken) { + api.getServers(authenticationToken).enqueue(new ServersResponse(context)); + } } diff --git a/src/main/java/org/amahi/anywhere/server/client/ServerClient.java b/src/main/java/org/amahi/anywhere/server/client/ServerClient.java index 446250d71..030842188 100644 --- a/src/main/java/org/amahi/anywhere/server/client/ServerClient.java +++ b/src/main/java/org/amahi/anywhere/server/client/ServerClient.java @@ -19,6 +19,7 @@ package org.amahi.anywhere.server.client; +import android.content.Context; import android.net.Uri; import com.squareup.otto.Subscribe; @@ -28,6 +29,7 @@ import org.amahi.anywhere.bus.ServerConnectedEvent; import org.amahi.anywhere.bus.ServerConnectionChangedEvent; import org.amahi.anywhere.bus.ServerConnectionDetectedEvent; +import org.amahi.anywhere.bus.ServerFileUploadCompleteEvent; import org.amahi.anywhere.bus.ServerRouteLoadedEvent; import org.amahi.anywhere.server.Api; import org.amahi.anywhere.server.ApiAdapter; @@ -40,18 +42,30 @@ import org.amahi.anywhere.server.model.ServerRoute; import org.amahi.anywhere.server.model.ServerShare; import org.amahi.anywhere.server.response.ServerAppsResponse; +import org.amahi.anywhere.server.response.ServerFileDeleteResponse; +import org.amahi.anywhere.server.response.ServerFileUploadResponse; import org.amahi.anywhere.server.response.ServerFilesResponse; import org.amahi.anywhere.server.response.ServerRouteResponse; import org.amahi.anywhere.server.response.ServerSharesResponse; import org.amahi.anywhere.task.ServerConnectionDetectingTask; +import org.amahi.anywhere.util.ProgressRequestBody; import org.amahi.anywhere.util.Time; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import java.io.File; import java.io.IOException; import javax.inject.Inject; import javax.inject.Singleton; +import okhttp3.MultipartBody; +import okhttp3.ResponseBody; import retrofit2.Callback; +import retrofit2.Response; + +import static org.amahi.anywhere.util.Android.loadServersFromAsset; /** @@ -59,181 +73,239 @@ * {@link org.amahi.anywhere.server.api.ServerApi}. Reacts to network connection changes as well. */ @Singleton -public class ServerClient -{ - private final ApiAdapter apiAdapter; - private final ProxyApi proxyApi; - private ServerApi serverApi; - - private Server server; - private ServerRoute serverRoute; - private String serverAddress; - private ApiConnection serverConnection; - - private int network; - - @Inject - public ServerClient(ApiAdapter apiAdapter) { - this.apiAdapter = apiAdapter; - this.proxyApi = buildProxyApi(); - - this.serverConnection = ApiConnection.AUTO; - - this.network = Integer.MIN_VALUE; - - setUpBus(); - } +public class ServerClient { + private final ApiAdapter apiAdapter; + private final ProxyApi proxyApi; + private ServerApi serverApi; - private ProxyApi buildProxyApi() { - return apiAdapter.create(ProxyApi.class, Api.getProxyUrl()); - } + private Server server; + private ServerRoute serverRoute; + private String serverAddress; + private ApiConnection serverConnection; - private void setUpBus() { - BusProvider.getBus().register(this); - } + private int network; - @Subscribe - public void onNetworkChanged(NetworkChangedEvent event) { - if (this.serverConnection != ApiConnection.AUTO) { - return; - } + @Inject + public ServerClient(ApiAdapter apiAdapter) { + this.apiAdapter = apiAdapter; + this.proxyApi = buildProxyApi(); - if (!isServerRouteLoaded()) { - return; - } + this.serverConnection = ApiConnection.AUTO; - if (this.network != event.getNetwork()) { - this.network = event.getNetwork(); + this.network = Integer.MIN_VALUE; - startServerConnectionDetection(); - } - } + setUpBus(); + } - private boolean isServerRouteLoaded() { - return serverRoute != null; - } + private ProxyApi buildProxyApi() { + return apiAdapter.create(ProxyApi.class, Api.getProxyUrl()); + } - private void startServerConnectionDetection() { - this.serverAddress = serverRoute.getLocalAddress(); - this.serverApi = buildServerApi(); + private void setUpBus() { + BusProvider.getBus().register(this); + } - ServerConnectionDetectingTask.execute(serverRoute); - } - - private ServerApi buildServerApi() { - return apiAdapter.create(ServerApi.class, serverAddress); - } - - @Subscribe - public void onServerConnectionDetected(ServerConnectionDetectedEvent event) { - this.serverAddress = event.getServerAddress(); - this.serverApi = buildServerApi(); + @Subscribe + public void onNetworkChanged(NetworkChangedEvent event) { + if (this.serverConnection != ApiConnection.AUTO) { + return; + } - finishServerConnectionDetection(); - } + if (!isServerRouteLoaded()) { + return; + } - private void finishServerConnectionDetection() { - BusProvider.getBus().post(new ServerConnectionChangedEvent()); - } + if (this.network != event.getNetwork()) { + this.network = event.getNetwork(); - public boolean isConnected() { - return (server != null) && (serverRoute != null) && (serverAddress != null); - } + startServerConnectionDetection(); + } + } + + private boolean isServerRouteLoaded() { + return serverRoute != null; + } + + private void startServerConnectionDetection() { + this.serverAddress = serverRoute.getLocalAddress(); + this.serverApi = buildServerApi(); + + ServerConnectionDetectingTask.execute(serverRoute); + } + + private ServerApi buildServerApi() { + return apiAdapter.create(ServerApi.class, serverAddress); + } + + @Subscribe + public void onServerConnectionDetected(ServerConnectionDetectedEvent event) { + this.serverAddress = event.getServerAddress(); + this.serverApi = buildServerApi(); + + finishServerConnectionDetection(); + } + + private void finishServerConnectionDetection() { + BusProvider.getBus().post(new ServerConnectionChangedEvent()); + } + + public boolean isConnected() { + return (server != null) && (serverRoute != null) && (serverAddress != null); + } + + public boolean isConnected(Server server) { + return (this.server != null) && (this.server.getSession().equals(server.getSession())); + } + + public boolean isConnectedLocal() { + return serverAddress.equals(serverRoute.getLocalAddress()); + } + + public void connect(Context context, Server server) { + this.server = server; + + if (server.isDebug()) { + try { + ServerRoute serverRoute = new ServerRoute(); + JSONArray jsonArray = new JSONArray(loadServersFromAsset(context)); + JSONObject jsonObject = jsonArray.getJSONObject(server.getIndex()); + serverRoute.setLocalAddress(jsonObject.getString("local_address")); + serverRoute.setRemoteAddress(jsonObject.getString("remote_address")); + BusProvider.getBus().post(new ServerRouteLoadedEvent(serverRoute)); + } catch (JSONException e) { + e.printStackTrace(); + } + } else { + startServerConnection(); + } + } - public boolean isConnected(Server server) { - return (this.server != null) && (this.server.getSession().equals(server.getSession())); - } + private void startServerConnection() { + proxyApi.getServerRoute(server.getSession()).enqueue(new ServerRouteResponse()); + } - public boolean isConnectedLocal() { - return serverAddress.equals(serverRoute.getLocalAddress()); - } + @Subscribe + public void onServerRouteLoaded(ServerRouteLoadedEvent event) { + this.serverRoute = event.getServerRoute(); - public void connect(Server server) { - this.server = server; + finishServerConnection(); + } - startServerConnection(); - } + private void finishServerConnection() { + BusProvider.getBus().post(new ServerConnectedEvent(server)); + } - private void startServerConnection() { - proxyApi.getServerRoute(server.getSession()).enqueue(new ServerRouteResponse()); - } + public void connectAuto() { + this.serverConnection = ApiConnection.AUTO; + if (!isServerRouteLoaded()) { + return; + } + startServerConnectionDetection(); + } - @Subscribe - public void onServerRouteLoaded(ServerRouteLoadedEvent event) { - this.serverRoute = event.getServerRoute(); + public void connectLocal() { + this.serverConnection = ApiConnection.LOCAL; + if (!isServerRouteLoaded()) { + return; + } + this.serverAddress = serverRoute.getLocalAddress(); + this.serverApi = buildServerApi(); + } - finishServerConnection(); - } + public void connectRemote() { + this.serverConnection = ApiConnection.REMOTE; + if (!isServerRouteLoaded()) { + return; + } + this.serverAddress = serverRoute.getRemoteAddress(); + this.serverApi = buildServerApi(); + } - private void finishServerConnection() { - BusProvider.getBus().post(new ServerConnectedEvent()); - } + public String getServerAddress() { + return serverAddress; + } - public void connectAuto() { - this.serverConnection = ApiConnection.AUTO; - if (!isServerRouteLoaded()) { - return; - } - startServerConnectionDetection(); - } + public void getShares() { + serverApi.getShares(server.getSession()).enqueue(new ServerSharesResponse()); + } - public void connectLocal() { - this.serverConnection = ApiConnection.LOCAL; - if (!isServerRouteLoaded()) { + public void getFiles(ServerShare share) { + if (share == null) { return; } - this.serverAddress = serverRoute.getLocalAddress(); - this.serverApi = buildServerApi(); - } - public void connectRemote() { - this.serverConnection = ApiConnection.REMOTE; - if (!isServerRouteLoaded()) { - return; + serverApi.getFiles(server.getSession(), share.getName(), null).enqueue(new ServerFilesResponse(share)); + } + + public void getFiles(ServerShare share, ServerFile directory) { + serverApi.getFiles(server.getSession(), share.getName(), directory.getPath()).enqueue(new ServerFilesResponse(directory, share)); + } + + public void deleteFile(ServerShare share, ServerFile serverFile) { + serverApi.deleteFile(server.getSession(), share.getName(), serverFile.getPath()) + .enqueue(new ServerFileDeleteResponse()); + } + + private MultipartBody.Part createFilePart(int id, File file) { + return MultipartBody.Part.createFormData("file", + file.getName(), + new ProgressRequestBody(id, file)); + } + + public void uploadFile(int id, File file, String shareName, String path) { + MultipartBody.Part filePart = createFilePart(id, file); + uploadFileAsync(id, filePart, shareName, path); + } + + public void uploadFile(int id, File file, ServerShare share, ServerFile directory) { + MultipartBody.Part filePart = createFilePart(id, file); + String path = "/"; + if (directory != null) + path = directory.getPath(); + uploadFileAsync(id, filePart, share.getName(), path); + } + + private void uploadFileAsync(int id, MultipartBody.Part filePart, String shareName, String path) { + serverApi.uploadFile(server.getSession(), shareName, path, filePart) + .enqueue(new ServerFileUploadResponse(id)); + } + + private void uploadFileSync(int id, MultipartBody.Part filePart, String shareName, String path) { + try { + Response response = serverApi + .uploadFile(server.getSession(), shareName, path, filePart) + .execute(); + if (response.isSuccessful()) { + BusProvider.getBus().post( + new ServerFileUploadCompleteEvent(id, true)); + } else { + BusProvider.getBus().post( + new ServerFileUploadCompleteEvent(id, false)); + } + } catch (IOException e) { + e.printStackTrace(); } - this.serverAddress = serverRoute.getRemoteAddress(); - this.serverApi = buildServerApi(); - } - - public String getServerAddress() { - return serverAddress; - } - - public void getShares() { - serverApi.getShares(server.getSession()).enqueue(new ServerSharesResponse()); - } - - public void getFiles(ServerShare share) { - if (share == null) { - return; - } - - serverApi.getFiles(server.getSession(), share.getName(), null).enqueue(new ServerFilesResponse(null)); - } - - public void getFiles(ServerShare share, ServerFile directory) { - serverApi.getFiles(server.getSession(), share.getName(), directory.getPath()).enqueue(new ServerFilesResponse(directory)); - } - - public Uri getFileUri(ServerShare share, ServerFile file) { - return Uri.parse(serverAddress) - .buildUpon() - .path("files") - .appendQueryParameter("s", share.getName()) - .appendQueryParameter("p", file.getPath()) - .appendQueryParameter("mtime", Time.getEpochTimeString(file.getModificationTime())) - .appendQueryParameter("session", server.getSession()) - .build(); - } - - public void getFileMetadata(ServerShare share, ServerFile file, Callback callback) { - if ((server == null) || (share == null) || (file == null)){ + } + + public Uri getFileUri(ServerShare share, ServerFile file) { + return Uri.parse(serverAddress) + .buildUpon() + .path("files") + .appendQueryParameter("s", share.getName()) + .appendQueryParameter("p", file.getPath()) + .appendQueryParameter("mtime", Time.getEpochTimeString(file.getModificationTime())) + .appendQueryParameter("session", server.getSession()) + .build(); + } + + public void getFileMetadata(ServerShare share, ServerFile file, Callback callback) { + if ((server == null) || (share == null) || (file == null)) { return; } - serverApi.getFileMetadata(server.getSession(), file.getName(), share.getTag()).enqueue(callback); - } + serverApi.getFileMetadata(server.getSession(), file.getName(), share.getTag()).enqueue(callback); + } - public void getApps() { - serverApi.getApps(server.getSession()).enqueue(new ServerAppsResponse()); - } + public void getApps() { + serverApi.getApps(server.getSession()).enqueue(new ServerAppsResponse()); + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/Authentication.java b/src/main/java/org/amahi/anywhere/server/model/Authentication.java index 59f50aa19..37ec3b38a 100644 --- a/src/main/java/org/amahi/anywhere/server/model/Authentication.java +++ b/src/main/java/org/amahi/anywhere/server/model/Authentication.java @@ -24,12 +24,11 @@ /** * Authentication API resource. */ -public class Authentication -{ - @SerializedName("access_token") - private String token; +public class Authentication { + @SerializedName("access_token") + private String token; - public String getToken() { - return token; - } + public String getToken() { + return token; + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/Server.java b/src/main/java/org/amahi/anywhere/server/model/Server.java index dabd2b30d..48f0b29cf 100644 --- a/src/main/java/org/amahi/anywhere/server/model/Server.java +++ b/src/main/java/org/amahi/anywhere/server/model/Server.java @@ -27,57 +27,74 @@ /** * Server API resource. */ -public class Server implements Parcelable -{ - @SerializedName("name") - private String name; +public class Server implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public Server createFromParcel(Parcel parcel) { + return new Server(parcel); + } - @SerializedName("session_token") - private String session; + @Override + public Server[] newArray(int size) { + return new Server[size]; + } + }; + @SerializedName("name") + private String name; + @SerializedName("session_token") + private String session; + @SerializedName("active") + private boolean active; + private boolean debug = false; + private int index; - @SerializedName("active") - private boolean active; + public Server(int index, String name, String session) { + this.index = index; + this.name = name; + this.session = session; + this.active = true; + this.debug = true; + } - public String getName() { - return name; - } + public Server(String session) { + this.session = session; + } - public String getSession() { - return session; - } + public Server(Parcel parcel) { + this.name = parcel.readString(); + this.session = parcel.readString(); + this.active = Boolean.valueOf(parcel.readString()); + } - public boolean isActive() { - return active; - } + public String getName() { + return name; + } - public static final Creator CREATOR = new Creator() - { - @Override - public Server createFromParcel(Parcel parcel) { - return new Server(parcel); - } + public String getSession() { + return session; + } - @Override - public Server[] newArray(int size) { - return new Server[size]; - } - }; + public int getIndex() { + return index; + } - private Server(Parcel parcel) { - this.name = parcel.readString(); - this.session = parcel.readString(); - this.active = Boolean.valueOf(parcel.readString()); - } + public boolean isActive() { + return active; + } - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeString(name); - parcel.writeString(session); - parcel.writeString(String.valueOf(active)); - } + public boolean isDebug() { + return debug; + } - @Override - public int describeContents() { - return 0; - } + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeString(name); + parcel.writeString(session); + parcel.writeString(String.valueOf(active)); + } + + @Override + public int describeContents() { + return 0; + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/ServerApp.java b/src/main/java/org/amahi/anywhere/server/model/ServerApp.java index dd0177c55..1f66aff5c 100644 --- a/src/main/java/org/amahi/anywhere/server/model/ServerApp.java +++ b/src/main/java/org/amahi/anywhere/server/model/ServerApp.java @@ -27,57 +27,52 @@ /** * App API resource. */ -public class ServerApp implements Parcelable -{ - @SerializedName("name") - private String name; +public class ServerApp implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public ServerApp createFromParcel(Parcel parcel) { + return new ServerApp(parcel); + } - @SerializedName("logo") - private String logoUrl; + @Override + public ServerApp[] newArray(int size) { + return new ServerApp[size]; + } + }; + @SerializedName("name") + private String name; + @SerializedName("logo") + private String logoUrl; + @SerializedName("vhost") + private String host; - @SerializedName("vhost") - private String host; + private ServerApp(Parcel parcel) { + this.name = parcel.readString(); + this.logoUrl = parcel.readString(); + this.host = parcel.readString(); + } - public String getName() { - return name; - } + public String getName() { + return name; + } - public String getLogoUrl() { - return logoUrl; - } + public String getLogoUrl() { + return logoUrl; + } - public String getHost() { - return host; - } + public String getHost() { + return host; + } - public static final Creator CREATOR = new Creator() - { - @Override - public ServerApp createFromParcel(Parcel parcel) { - return new ServerApp(parcel); - } + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeString(name); + parcel.writeString(logoUrl); + parcel.writeString(host); + } - @Override - public ServerApp[] newArray(int size) { - return new ServerApp[size]; - } - }; - - private ServerApp(Parcel parcel) { - this.name = parcel.readString(); - this.logoUrl = parcel.readString(); - this.host = parcel.readString(); - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeString(name); - parcel.writeString(logoUrl); - parcel.writeString(host); - } - - @Override - public int describeContents() { - return 0; - } + @Override + public int describeContents() { + return 0; + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/ServerFile.java b/src/main/java/org/amahi/anywhere/server/model/ServerFile.java index 845c0c919..8219bcc8d 100644 --- a/src/main/java/org/amahi/anywhere/server/model/ServerFile.java +++ b/src/main/java/org/amahi/anywhere/server/model/ServerFile.java @@ -30,49 +30,74 @@ /** * File API resource. */ -public class ServerFile implements Parcelable -{ - private ServerFile parentFile; - - @SerializedName("name") - private String name; - - @SerializedName("mime_type") - private String mime; - - @SerializedName("mtime") - private Date modificationTime; - +public class ServerFile implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public ServerFile createFromParcel(Parcel parcel) { + return new ServerFile(parcel); + } + + @Override + public ServerFile[] newArray(int size) { + return new ServerFile[size]; + } + }; + private ServerFile parentFile; + private ServerShare parentShare; + @SerializedName("name") + private String name; + @SerializedName("mime_type") + private String mime; + @SerializedName("mtime") + private Date modificationTime; @SerializedName("size") private long size; - private ServerFileMetadata fileMetadata; - - private boolean isMetaDataFetched; + private boolean isMetaDataFetched; + + private ServerFile(Parcel parcel) { + this.parentFile = parcel.readParcelable(ServerFile.class.getClassLoader()); + this.name = parcel.readString(); + this.mime = parcel.readString(); + this.modificationTime = new Date(parcel.readLong()); + this.size = parcel.readLong(); + } public long getSize() { - return size; - } + return size; + } + + public ServerShare getParentShare() { + return parentShare; + } + + public void setParentShare(ServerShare parentShare) { + this.parentShare = parentShare; + } + + public ServerFile getParentFile() { + return parentFile; + } public void setParentFile(ServerFile parentFile) { - this.parentFile = parentFile; - } + this.parentFile = parentFile; + } - public ServerFile getParentFile() { - return parentFile; - } + public String getName() { + return name; + } - public String getName() { - return name; - } + public String getNameOnly() { + return name.replace("." + getExtension(), ""); + } - public String getMime() { - return mime; - } + public String getMime() { + return mime; + } - public Date getModificationTime() { - return modificationTime; - } + public Date getModificationTime() { + return modificationTime; + } public ServerFileMetadata getFileMetadata() { return fileMetadata; @@ -82,93 +107,81 @@ public void setFileMetadata(ServerFileMetadata fileMetadata) { this.fileMetadata = fileMetadata; } - public boolean isMetaDataFetched() { - return isMetaDataFetched; - } - - public void setMetaDataFetched(boolean metaDataFetched) { - isMetaDataFetched = metaDataFetched; - } - - public String getPath() { - Uri.Builder uri = new Uri.Builder(); - - if (parentFile != null) { - uri.appendPath(parentFile.getPath()); - } - - uri.appendPath(name); - - return uri.build().getPath(); - } - - public static final Creator CREATOR = new Creator() - { - @Override - public ServerFile createFromParcel(Parcel parcel) { - return new ServerFile(parcel); - } - - @Override - public ServerFile[] newArray(int size) { - return new ServerFile[size]; - } - }; - - private ServerFile(Parcel parcel) { - this.parentFile = parcel.readParcelable(ServerFile.class.getClassLoader()); - this.name = parcel.readString(); - this.mime = parcel.readString(); - this.modificationTime = new Date(parcel.readLong()); - this.size= parcel.readLong(); - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeParcelable(parentFile, flags); - parcel.writeString(name); - parcel.writeString(mime); - parcel.writeLong(modificationTime.getTime()); - parcel.writeLong(size); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - - if (object == null) { - return false; - } - - if (getClass() != object.getClass()) { - return false; - } - - ServerFile file = (ServerFile) object; - - if ((parentFile != null) && (!parentFile.equals(file.parentFile))) { - return false; - } - - if (!name.equals(file.name)) { - return false; - } - - if (!mime.equals(file.mime)) { - return false; - } - - if (!modificationTime.equals(file.modificationTime)) { - return false; - } - - return true; - } + public boolean isMetaDataFetched() { + return isMetaDataFetched; + } + + public void setMetaDataFetched(boolean metaDataFetched) { + isMetaDataFetched = metaDataFetched; + } + + public String getPath() { + Uri.Builder uri = new Uri.Builder(); + + if (parentFile != null) { + uri.appendPath(parentFile.getPath()); + } + + uri.appendPath(name); + + return uri.build().getPath(); + } + + public String getExtension() { + String[] splitString = name.split("\\."); + if (splitString.length > 1) { + return splitString[splitString.length - 1]; + } else { + return ""; + } + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeParcelable(parentFile, flags); + parcel.writeString(name); + parcel.writeString(mime); + parcel.writeLong(modificationTime.getTime()); + parcel.writeLong(size); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + + if (object == null) { + return false; + } + + if (getClass() != object.getClass()) { + return false; + } + + ServerFile file = (ServerFile) object; + + if ((parentFile != null) && (!parentFile.equals(file.parentFile))) { + return false; + } + + if (!name.equals(file.name)) { + return false; + } + + if (!mime.equals(file.mime)) { + return false; + } + + if (!modificationTime.equals(file.modificationTime)) { + return false; + } + + return true; + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/ServerFileMetadata.java b/src/main/java/org/amahi/anywhere/server/model/ServerFileMetadata.java index d970881ef..571537f5d 100644 --- a/src/main/java/org/amahi/anywhere/server/model/ServerFileMetadata.java +++ b/src/main/java/org/amahi/anywhere/server/model/ServerFileMetadata.java @@ -24,48 +24,44 @@ import com.google.gson.annotations.SerializedName; -public class ServerFileMetadata implements Parcelable -{ - @SerializedName("title") - private String title; +public class ServerFileMetadata implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public ServerFileMetadata createFromParcel(Parcel parcel) { + return new ServerFileMetadata(parcel); + } - @SerializedName("artwork") - private String artworkUrl; + @Override + public ServerFileMetadata[] newArray(int size) { + return new ServerFileMetadata[size]; + } + }; + @SerializedName("title") + private String title; + @SerializedName("artwork") + private String artworkUrl; - public String getTitle() { - return title; - } + private ServerFileMetadata(Parcel parcel) { + this.title = parcel.readString(); + this.artworkUrl = parcel.readString(); + } - public String getArtworkUrl() { - return artworkUrl; - } + public String getTitle() { + return title; + } - public static final Creator CREATOR = new Creator() - { - @Override - public ServerFileMetadata createFromParcel(Parcel parcel) { - return new ServerFileMetadata(parcel); - } + public String getArtworkUrl() { + return artworkUrl; + } - @Override - public ServerFileMetadata[] newArray(int size) { - return new ServerFileMetadata[size]; - } - }; + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeString(title); + parcel.writeString(artworkUrl); + } - private ServerFileMetadata(Parcel parcel) { - this.title = parcel.readString(); - this.artworkUrl = parcel.readString(); - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeString(title); - parcel.writeString(artworkUrl); - } - - @Override - public int describeContents() { - return 0; - } + @Override + public int describeContents() { + return 0; + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/ServerRoute.java b/src/main/java/org/amahi/anywhere/server/model/ServerRoute.java index f463f3ebf..5241e1b9b 100644 --- a/src/main/java/org/amahi/anywhere/server/model/ServerRoute.java +++ b/src/main/java/org/amahi/anywhere/server/model/ServerRoute.java @@ -24,19 +24,26 @@ /** * Server route API resource. */ -public class ServerRoute -{ - @SerializedName("local_addr") - private String localAddress; +public class ServerRoute { + @SerializedName("local_addr") + private String localAddress; - @SerializedName("relay_addr") - private String remoteAddress; + @SerializedName("relay_addr") + private String remoteAddress; - public String getLocalAddress() { - return localAddress; - } + public String getLocalAddress() { + return localAddress; + } - public String getRemoteAddress() { - return remoteAddress; - } + public void setLocalAddress(String localAddress) { + this.localAddress = localAddress; + } + + public String getRemoteAddress() { + return remoteAddress; + } + + public void setRemoteAddress(String remoteAddress) { + this.remoteAddress = remoteAddress; + } } diff --git a/src/main/java/org/amahi/anywhere/server/model/ServerShare.java b/src/main/java/org/amahi/anywhere/server/model/ServerShare.java index 808c4a695..ed9623a4e 100644 --- a/src/main/java/org/amahi/anywhere/server/model/ServerShare.java +++ b/src/main/java/org/amahi/anywhere/server/model/ServerShare.java @@ -29,70 +29,64 @@ /** * Share API resource. */ -public class ServerShare implements Parcelable -{ - public static final class Tag - { - private Tag() { - } - - public static final String FILES = "files"; - - public static final String MOVIES = "movie"; - public static final String TV = "tv"; - } - - @SerializedName("name") - private String name; - - @SerializedName("tags") - private List tags; - - public String getName() { - return name; - } - - public String getTag() { - for (String tag : tags) { - if (tag.contains(Tag.MOVIES)) { - return Tag.MOVIES; - } - - if (tag.contains(Tag.TV)) { - return Tag.TV; - } - } - - return Tag.FILES; - } - - public static final Creator CREATOR = new Creator() - { - @Override - public ServerShare createFromParcel(Parcel parcel) { - return new ServerShare(parcel); - } - - @Override - public ServerShare[] newArray(int size) { - return new ServerShare[size]; - } - }; - - @SuppressWarnings("unchecked") - private ServerShare(Parcel parcel) { - this.name = parcel.readString(); - this.tags = parcel.readArrayList(String.class.getClassLoader()); - } - - @Override - public void writeToParcel(Parcel parcel, int flags) { - parcel.writeString(name); - parcel.writeList(tags); - } - - @Override - public int describeContents() { - return 0; - } +public class ServerShare implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public ServerShare createFromParcel(Parcel parcel) { + return new ServerShare(parcel); + } + + @Override + public ServerShare[] newArray(int size) { + return new ServerShare[size]; + } + }; + @SerializedName("name") + private String name; + + @SerializedName("tags") + private List tags; + + @SuppressWarnings("unchecked") + private ServerShare(Parcel parcel) { + this.name = parcel.readString(); + this.tags = parcel.readArrayList(String.class.getClassLoader()); + } + + public String getName() { + return name; + } + + public String getTag() { + for (String tag : tags) { + if (tag.contains(Tag.MOVIES)) { + return Tag.MOVIES; + } + + if (tag.contains(Tag.TV)) { + return Tag.TV; + } + } + + return Tag.FILES; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeString(name); + parcel.writeList(tags); + } + + @Override + public int describeContents() { + return 0; + } + + public static final class Tag { + public static final String FILES = "files"; + public static final String MOVIES = "movie"; + public static final String TV = "tv"; + private Tag() { + } + } } diff --git a/src/main/java/org/amahi/anywhere/server/response/AuthenticationResponse.java b/src/main/java/org/amahi/anywhere/server/response/AuthenticationResponse.java index 6b0919a07..59c104732 100644 --- a/src/main/java/org/amahi/anywhere/server/response/AuthenticationResponse.java +++ b/src/main/java/org/amahi/anywhere/server/response/AuthenticationResponse.java @@ -36,22 +36,21 @@ * Authentication response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} * as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class AuthenticationResponse implements Callback -{ - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) - BusProvider.getBus().post(new AuthenticationSucceedEvent(response.body())); - else - this.onFailure(call, new HttpException(response)); - } +public class AuthenticationResponse implements Callback { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) + BusProvider.getBus().post(new AuthenticationSucceedEvent(response.body())); + else + this.onFailure(call, new HttpException(response)); + } - @Override - public void onFailure(Call call, Throwable t) { - if (t instanceof IOException) { //implies no network connection - BusProvider.getBus().post(new AuthenticationConnectionFailedEvent()); - } else { - BusProvider.getBus().post(new AuthenticationFailedEvent()); - } - } + @Override + public void onFailure(Call call, Throwable t) { + if (t instanceof IOException) { //implies no network connection + BusProvider.getBus().post(new AuthenticationConnectionFailedEvent()); + } else { + BusProvider.getBus().post(new AuthenticationFailedEvent()); + } + } } diff --git a/src/main/java/org/amahi/anywhere/server/response/ServerAppsResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServerAppsResponse.java index 33127ef0a..327fa0c5d 100644 --- a/src/main/java/org/amahi/anywhere/server/response/ServerAppsResponse.java +++ b/src/main/java/org/amahi/anywhere/server/response/ServerAppsResponse.java @@ -35,18 +35,17 @@ * Apps response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} * as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class ServerAppsResponse implements Callback> -{ - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful()) - BusProvider.getBus().post(new ServerAppsLoadedEvent(response.body())); - else - this.onFailure(call, new HttpException(response)); - } +public class ServerAppsResponse implements Callback> { + @Override + public void onResponse(Call> call, Response> response) { + if (response.isSuccessful()) + BusProvider.getBus().post(new ServerAppsLoadedEvent(response.body())); + else + this.onFailure(call, new HttpException(response)); + } - @Override - public void onFailure(Call> call, Throwable t) { - BusProvider.getBus().post(new ServerAppsLoadFailedEvent()); - } + @Override + public void onFailure(Call> call, Throwable t) { + BusProvider.getBus().post(new ServerAppsLoadFailedEvent()); + } } diff --git a/src/main/java/org/amahi/anywhere/server/response/ServerFileDeleteResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServerFileDeleteResponse.java new file mode 100644 index 000000000..a1f8880e5 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/server/response/ServerFileDeleteResponse.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.server.response; + +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerFileDeleteEvent; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.HttpException; +import retrofit2.Response; + + +/** + * File delete response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} + * as {@link org.amahi.anywhere.bus.BusEvent}. + */ +public class ServerFileDeleteResponse implements Callback { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + BusProvider.getBus().post(new ServerFileDeleteEvent(true)); + } else + this.onFailure(call, new HttpException(response)); + } + + @Override + public void onFailure(Call call, Throwable t) { + BusProvider.getBus().post(new ServerFileDeleteEvent(false)); + } +} diff --git a/src/main/java/org/amahi/anywhere/server/response/ServerFileUploadResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServerFileUploadResponse.java new file mode 100644 index 000000000..9b2cbafe2 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/server/response/ServerFileUploadResponse.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.server.response; + +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerFileUploadCompleteEvent; + +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.HttpException; +import retrofit2.Response; + + +/** + * File upload response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} + * as {@link org.amahi.anywhere.bus.BusEvent}. + */ +public class ServerFileUploadResponse implements Callback { + private int id; + + public ServerFileUploadResponse(int id) { + this.id = id; + } + + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + BusProvider.getBus().post(new ServerFileUploadCompleteEvent(id, true)); + } else + this.onFailure(call, new HttpException(response)); + } + + @Override + public void onFailure(Call call, Throwable t) { + BusProvider.getBus().post(new ServerFileUploadCompleteEvent(id, false)); + } +} diff --git a/src/main/java/org/amahi/anywhere/server/response/ServerFilesResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServerFilesResponse.java index 4cb300128..a514d2d99 100644 --- a/src/main/java/org/amahi/anywhere/server/response/ServerFilesResponse.java +++ b/src/main/java/org/amahi/anywhere/server/response/ServerFilesResponse.java @@ -23,6 +23,7 @@ import org.amahi.anywhere.bus.ServerFilesLoadFailedEvent; import org.amahi.anywhere.bus.ServerFilesLoadedEvent; import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; import java.util.Collections; import java.util.List; @@ -37,34 +38,39 @@ * Files response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} * as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class ServerFilesResponse implements Callback> -{ - private final ServerFile serverDirectory; +public class ServerFilesResponse implements Callback> { + private ServerFile serverDirectory; + private ServerShare serverShare; - public ServerFilesResponse(ServerFile serverDirectory) { - this.serverDirectory = serverDirectory; - } + public ServerFilesResponse(ServerFile serverDirectory, ServerShare serverShare) { + this.serverDirectory = serverDirectory; + this.serverShare = serverShare; + } - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful()) { - List serverFiles = response.body(); - if (serverFiles == null) { - serverFiles = Collections.emptyList(); - } + public ServerFilesResponse(ServerShare serverShare) { + this.serverShare = serverShare; + } - for (ServerFile serverFile : serverFiles) { - serverFile.setParentFile(serverDirectory); - } + @Override + public void onResponse(Call> call, Response> response) { + if (response.isSuccessful()) { + List serverFiles = response.body(); + if (serverFiles == null) { + serverFiles = Collections.emptyList(); + } - BusProvider.getBus().post(new ServerFilesLoadedEvent(serverFiles)); - } - else - this.onFailure(call, new HttpException(response)); - } + for (ServerFile serverFile : serverFiles) { + serverFile.setParentFile(serverDirectory); + serverFile.setParentShare(serverShare); + } - @Override - public void onFailure(Call> call, Throwable t) { - BusProvider.getBus().post(new ServerFilesLoadFailedEvent()); - } + BusProvider.getBus().post(new ServerFilesLoadedEvent(serverFiles)); + } else + this.onFailure(call, new HttpException(response)); + } + + @Override + public void onFailure(Call> call, Throwable t) { + BusProvider.getBus().post(new ServerFilesLoadFailedEvent()); + } } diff --git a/src/main/java/org/amahi/anywhere/server/response/ServerRouteResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServerRouteResponse.java index d38d949b7..1d8db0de4 100644 --- a/src/main/java/org/amahi/anywhere/server/response/ServerRouteResponse.java +++ b/src/main/java/org/amahi/anywhere/server/response/ServerRouteResponse.java @@ -33,8 +33,7 @@ * Server route response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} * as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class ServerRouteResponse implements Callback -{ +public class ServerRouteResponse implements Callback { @Override public void onResponse(Call call, Response response) { if (response.isSuccessful()) diff --git a/src/main/java/org/amahi/anywhere/server/response/ServerSharesResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServerSharesResponse.java index 4755f3132..db3fa108e 100644 --- a/src/main/java/org/amahi/anywhere/server/response/ServerSharesResponse.java +++ b/src/main/java/org/amahi/anywhere/server/response/ServerSharesResponse.java @@ -35,18 +35,17 @@ * Shares response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} * as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class ServerSharesResponse implements Callback> -{ - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful()) - BusProvider.getBus().post(new ServerSharesLoadedEvent(response.body())); - else - this.onFailure(call, new HttpException(response)); - } +public class ServerSharesResponse implements Callback> { + @Override + public void onResponse(Call> call, Response> response) { + if (response.isSuccessful()) + BusProvider.getBus().post(new ServerSharesLoadedEvent(response.body())); + else + this.onFailure(call, new HttpException(response)); + } - @Override - public void onFailure(Call> call, Throwable t) { - BusProvider.getBus().post(new ServerSharesLoadFailedEvent()); - } + @Override + public void onFailure(Call> call, Throwable t) { + BusProvider.getBus().post(new ServerSharesLoadFailedEvent()); + } } diff --git a/src/main/java/org/amahi/anywhere/server/response/ServersResponse.java b/src/main/java/org/amahi/anywhere/server/response/ServersResponse.java index e136cedbe..cad888a6f 100644 --- a/src/main/java/org/amahi/anywhere/server/response/ServersResponse.java +++ b/src/main/java/org/amahi/anywhere/server/response/ServersResponse.java @@ -19,11 +19,18 @@ package org.amahi.anywhere.server.response; +import android.content.Context; + +import org.amahi.anywhere.BuildConfig; import org.amahi.anywhere.bus.BusProvider; import org.amahi.anywhere.bus.ServersLoadFailedEvent; import org.amahi.anywhere.bus.ServersLoadedEvent; import org.amahi.anywhere.server.model.Server; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import java.util.ArrayList; import java.util.List; import retrofit2.Call; @@ -31,22 +38,50 @@ import retrofit2.HttpException; import retrofit2.Response; +import static org.amahi.anywhere.util.Android.loadServersFromAsset; + /** * Servers response proxy. Consumes API callback and posts it via {@link com.squareup.otto.Bus} * as {@link org.amahi.anywhere.bus.BusEvent}. */ -public class ServersResponse implements Callback> -{ - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful()) - BusProvider.getBus().post(new ServersLoadedEvent(response.body())); - else - this.onFailure(call, new HttpException(response)); - } - - @Override - public void onFailure(Call> call, Throwable t) { - BusProvider.getBus().post(new ServersLoadFailedEvent()); - } +public class ServersResponse implements Callback> { + private Context context; + + public ServersResponse(Context context) { + this.context = context; + } + + private List getLocalServers() { + List servers = new ArrayList<>(); + try { + JSONArray jsonArray = new JSONArray(loadServersFromAsset(context)); + for (int i = 0; i < jsonArray.length(); i++) { + JSONObject jsonObject = jsonArray.getJSONObject(i); + Server server = new Server(i, jsonObject.getString("name"), + jsonObject.getString("session_token")); + servers.add(server); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return servers; + } + + + @Override + public void onResponse(Call> call, Response> response) { + if (response.isSuccessful()) { + List servers = response.body(); + if (BuildConfig.DEBUG) { + servers.addAll(getLocalServers()); + } + BusProvider.getBus().post(new ServersLoadedEvent(servers)); + } else + this.onFailure(call, new HttpException(response)); + } + + @Override + public void onFailure(Call> call, Throwable t) { + BusProvider.getBus().post(new ServersLoadFailedEvent()); + } } diff --git a/src/main/java/org/amahi/anywhere/service/AudioService.java b/src/main/java/org/amahi/anywhere/service/AudioService.java index d6e3f1b2e..7a72535f6 100644 --- a/src/main/java/org/amahi/anywhere/service/AudioService.java +++ b/src/main/java/org/amahi/anywhere/service/AudioService.java @@ -75,457 +75,465 @@ * Places information at {@link android.app.Notification} and {@link MediaSessionCompat}, * handles audio focus changes as well. */ -public class AudioService extends MediaBrowserServiceCompat implements AudioManager.OnAudioFocusChangeListener, - MediaPlayer.OnPreparedListener, - MediaPlayer.OnCompletionListener, - MediaPlayer.OnErrorListener -{ - private MediaNotificationManager mMediaNotificationManager; - - private enum AudioFocus - { - GAIN, LOSS - } - - private MediaPlayer audioPlayer; - private MediaSessionCompat mediaSession; - private AudioFocus audioFocus; - - private ServerShare audioShare; - private List audioFiles; - private ServerFile audioFile; - - private AudioMetadataFormatter audioMetadataFormatter; - private Bitmap audioAlbumArt; - - @Inject - ServerClient serverClient; - - @Override - public IBinder onBind(Intent intent) { - return new AudioServiceBinder(this); - } - - @Override - public void onCreate() { - super.onCreate(); - - setUpInjections(); - - setUpBus(); - - setUpAudioPlayer(); - setUpAudioPlayerRemote(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - MediaButtonReceiver.handleIntent(mediaSession, intent); - return super.onStartCommand(intent, flags, startId); - } - - @Nullable - @Override - public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) { - if(TextUtils.equals(clientPackageName, getPackageName())) { - return new BrowserRoot(getString(R.string.application_name), null); - } - - return null; - } - - //Not important for general audio service, required for class - @Override - public void onLoadChildren(@NonNull String parentId, @NonNull Result> result) { - result.sendResult(null); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpBus() { - BusProvider.getBus().register(this); - } - - private void setUpAudioPlayer() { - audioPlayer = new MediaPlayer(); - - audioPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); - audioPlayer.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK); - audioPlayer.setVolume(1.0f, 1.0f); - - audioPlayer.setOnPreparedListener(this); - audioPlayer.setOnCompletionListener(this); - audioPlayer.setOnErrorListener(this); - } - - private void setUpAudioPlayerRemote() { - AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); - ComponentName audioReceiver = new ComponentName(getPackageName(), AudioReceiver.class.getName()); - - Intent audioIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); - audioIntent.setComponent(audioReceiver); - PendingIntent audioPendingIntent = PendingIntent.getBroadcast(this, 0, audioIntent, 0); - - mediaSession = new MediaSessionCompat(this, "PlayerService", audioReceiver, audioPendingIntent); - mediaSession.setCallback(new MediaSessionCallback()); - mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | - MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); - mediaSession.setMediaButtonReceiver(audioPendingIntent); - setSessionToken(mediaSession.getSessionToken()); - - try { - mMediaNotificationManager = new MediaNotificationManager(this); - } catch (RemoteException e) { - throw new IllegalStateException("Could not create a MediaNotificationManager", e); - } - - mediaSession.setPlaybackState(new PlaybackStateCompat.Builder() - .setState(PlaybackStateCompat.STATE_NONE, 0, 0) - .setActions(getAvailableActions()) - .build()); - - audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); - } - - public boolean isAudioStarted() { - return (audioShare != null) && (audioFiles != null) && (audioFile != null); - } - - public void startAudio(ServerShare audioShare, List audioFiles, ServerFile audioFile) { - this.audioShare = audioShare; - this.audioFiles = audioFiles; - this.audioFile = audioFile; - - setUpAudioPlayback(); - setUpAudioMetadata(); - } - - private void setUpAudioPlayback() { - try { - audioPlayer.setDataSource(this, getAudioUri()); - audioPlayer.prepareAsync(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private Uri getAudioUri() { - return serverClient.getFileUri(audioShare, audioFile); - } - - @Override - public void onPrepared(MediaPlayer audioPlayer) { - BusProvider.getBus().post(new AudioPreparedEvent()); - playAudio(); - } - - private void setUpAudioMetadata() { - AudioMetadataRetrievingTask.execute(getAudioUri()); - } - - @Subscribe - public void onAudioMetadataRetrieved(AudioMetadataRetrievedEvent event) { - if (audioFile != null) { - this.audioMetadataFormatter = new AudioMetadataFormatter( - event.getAudioTitle(), event.getAudioArtist(), event.getAudioAlbum()); - this.audioAlbumArt = event.getAudioAlbumArt(); - - setUpAudioPlayerRemote(audioMetadataFormatter, audioAlbumArt); - - mMediaNotificationManager.startNotification(); - } - } - - private void setUpAudioPlayerRemote(AudioMetadataFormatter audioMetadataFormatter, Bitmap audioAlbumArt) { - - mediaSession.setMetadata(new MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, audioMetadataFormatter.getAudioTitle(audioFile)) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, audioMetadataFormatter.getAudioSubtitle(audioShare)) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, audioMetadataFormatter.getAudioSubtitle(audioShare)) - .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, getAudioPlayerRemoteArtwork(audioAlbumArt)) - .build()); - } - - private Bitmap getAudioPlayerRemoteArtwork(Bitmap audioAlbumArt) { - if (audioAlbumArt == null) { - return null; - } - - Bitmap.Config artworkConfig = audioAlbumArt.getConfig(); - - if (artworkConfig == null) { - artworkConfig = Bitmap.Config.ARGB_8888; - } - - return audioAlbumArt.copy(artworkConfig, false); - } - - public PendingIntent createContentIntent() { - Intent audioIntent = Intents.Builder.with(this).buildServerFileIntent(audioShare, audioFiles, audioFile); - return PendingIntent.getActivity(this, 0, audioIntent, PendingIntent.FLAG_CANCEL_CURRENT); - } - - public ServerFile getAudioFile() { - return audioFile; - } - - public AudioMetadataFormatter getAudioMetadataFormatter() { - return audioMetadataFormatter; - } - - public Bitmap getAudioAlbumArt() { - return audioAlbumArt; - } - - public MediaPlayer getAudioPlayer() { - return audioPlayer; - } - - @Subscribe - public void onAudioControlPlayPause(AudioControlPlayPauseEvent event) { - if (audioPlayer.isPlaying()) { - pauseAudio(); - } else { - playAudio(); - } - } - - @Subscribe - public void onAudioControlPlay(AudioControlPlayEvent event) { - playAudio(); - } - - @Subscribe - public void onAudioControlPause(AudioControlPauseEvent event) { - pauseAudio(); - } - - @Subscribe - public void onAudioControlNext(AudioControlNextEvent event) { - startNextAudio(); - } - - @Subscribe - public void onAudioControlPrevious(AudioControlPreviousEvent event) { - startPreviousAudio(); - } - - public void playAudio() { - mediaSession.setActive(true); - audioPlayer.start(); - setMediaPlaybackState(PlaybackStateCompat.STATE_PLAYING); - } - - public void pauseAudio() { - mediaSession.setActive(false); - audioPlayer.pause(); - setMediaPlaybackState(PlaybackStateCompat.STATE_PAUSED); - } - - private void setMediaPlaybackState(int state) { - PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder(); - playbackStateBuilder.setActions(getAvailableActions()); - playbackStateBuilder.setState(state, audioPlayer.getCurrentPosition(), 1.0f, SystemClock.elapsedRealtime()); - mediaSession.setPlaybackState(playbackStateBuilder.build()); - } - - private long getAvailableActions() { - long actions = PlaybackStateCompat.ACTION_PLAY_PAUSE | - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS | - PlaybackStateCompat.ACTION_SKIP_TO_NEXT; - if (audioPlayer.isPlaying()) { - actions |= PlaybackStateCompat.ACTION_PAUSE; - } else { - actions |= PlaybackStateCompat.ACTION_PLAY; - } - return actions; - } - - private void startNextAudio() { - this.audioFile = getNextAudioFile(); - - tearDownAudioPlayback(); - - setUpAudioPlayback(); - setUpAudioMetadata(); - } - - private ServerFile getNextAudioFile() { - int currentAudioFilePosition = audioFiles.indexOf(audioFile); - - if (currentAudioFilePosition == audioFiles.size() - 1) { - return audioFiles.get(0); - } - - return audioFiles.get(currentAudioFilePosition + 1); - } - - private void tearDownAudioPlayback() { - if(isAudioPlaying()) - pauseAudio(); - audioPlayer.reset(); - } - - private void startPreviousAudio() { - this.audioFile = getPreviousAudioFile(); - - tearDownAudioPlayback(); - - setUpAudioPlayback(); - setUpAudioMetadata(); - } - - private ServerFile getPreviousAudioFile() { - int currentAudioFilePosition = audioFiles.indexOf(audioFile); - - if (currentAudioFilePosition == 0) { - return audioFiles.get(audioFiles.size() - 1); - } - - return audioFiles.get(currentAudioFilePosition - 1); - } - - @Override - public void onAudioFocusChange(int audioFocus) { - switch (audioFocus) { - case AudioManager.AUDIOFOCUS_GAIN: - handleAudioFocusGain(); - break; - - case AudioManager.AUDIOFOCUS_LOSS: - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - handleAudioFocusLoss(); - break; - - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - handleAudioFocusDuck(); - break; - - default: - break; - } - } - - private void handleAudioFocusGain() { - if (isAudioPlaying()) { - setUpAudioVolume(); - } else { - if (audioFocus == AudioFocus.LOSS) { - playAudio(); - } - } - - this.audioFocus = AudioFocus.GAIN; - } - - private boolean isAudioPlaying() { - try { - return isAudioStarted() && audioPlayer.isPlaying(); - } catch (IllegalStateException e) { - return false; - } - } - - private void setUpAudioVolume() { - audioPlayer.setVolume(1.0f, 1.0f); - } - - private void handleAudioFocusLoss() { - if (isAudioPlaying()) { - pauseAudio(); - } - - this.audioFocus = AudioFocus.LOSS; - } - - private void handleAudioFocusDuck() { - if (isAudioPlaying()) { - tearDownAudioVolume(); - } - } - - private void tearDownAudioVolume() { - audioPlayer.setVolume(0.3f, 0.3f); - } - - @Override - public void onCompletion(MediaPlayer audioPlayer) { - BusProvider.getBus().post(new AudioCompletedEvent()); - - startNextAudio(); - } - - @Override - public boolean onError(MediaPlayer audioPlayer, int errorReason, int errorExtra) { - getAudioPlayer().reset(); - return true; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - tearDownBus(); - - tearDownAudioPlayer(); - tearDownAudioPlayerRemote(); - tearDownAudioPlayerNotification(); - } - - private void tearDownBus() { - BusProvider.getBus().unregister(this); - } - - private void tearDownAudioPlayer() { - audioPlayer.reset(); - audioPlayer.release(); - } - - private void tearDownAudioPlayerRemote() { - AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); - - audioManager.abandonAudioFocus(this); - mediaSession.release(); - } - - private void tearDownAudioPlayerNotification() { - mMediaNotificationManager.stopNotification(); - } - - public static final class AudioServiceBinder extends Binder - { - private final AudioService audioService; - - public AudioServiceBinder(AudioService audioService) { - this.audioService = audioService; - } - - public AudioService getAudioService() { - return audioService; - } - } - - private class MediaSessionCallback extends MediaSessionCompat.Callback { - @Override - public void onPlay() { - playAudio(); - } - - @Override - public void onPause() { - pauseAudio(); - } - - @Override - public void onSkipToNext() { - startNextAudio(); - } - - @Override - public void onSkipToPrevious() { - startPreviousAudio(); - } - } +public class AudioService extends MediaBrowserServiceCompat implements + AudioManager.OnAudioFocusChangeListener, + MediaPlayer.OnPreparedListener, + MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener { + @Inject + ServerClient serverClient; + private MediaNotificationManager mMediaNotificationManager; + private MediaPlayer audioPlayer; + private MediaSessionCompat mediaSession; + private AudioFocus audioFocus; + + private ServerShare audioShare; + private List audioFiles; + private ServerFile audioFile; + + private AudioMetadataFormatter audioMetadataFormatter; + private Bitmap audioAlbumArt; + + @Override + public IBinder onBind(Intent intent) { + return new AudioServiceBinder(this); + } + + @Override + public void onCreate() { + super.onCreate(); + + setUpInjections(); + + setUpBus(); + + setUpAudioPlayer(); + setUpAudioPlayerRemote(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + MediaButtonReceiver.handleIntent(mediaSession, intent); + return super.onStartCommand(intent, flags, startId); + } + + @Nullable + @Override + public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) { + if (TextUtils.equals(clientPackageName, getPackageName())) { + return new BrowserRoot(getString(R.string.application_name), null); + } + + return null; + } + + //Not important for general audio service, required for class + @Override + public void onLoadChildren(@NonNull String parentId, @NonNull Result> result) { + result.sendResult(null); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpBus() { + BusProvider.getBus().register(this); + } + + private void setUpAudioPlayer() { + audioPlayer = new MediaPlayer(); + + audioPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + audioPlayer.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK); + audioPlayer.setVolume(1.0f, 1.0f); + + audioPlayer.setOnPreparedListener(this); + audioPlayer.setOnCompletionListener(this); + audioPlayer.setOnErrorListener(this); + } + + private void setUpAudioPlayerRemote() { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + ComponentName audioReceiver = new ComponentName(getPackageName(), AudioReceiver.class.getName()); + + Intent audioIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + audioIntent.setComponent(audioReceiver); + PendingIntent audioPendingIntent = PendingIntent.getBroadcast(this, 0, audioIntent, 0); + + mediaSession = new MediaSessionCompat(this, "PlayerService", audioReceiver, audioPendingIntent); + mediaSession.setCallback(new MediaSessionCallback()); + mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | + MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); + mediaSession.setMediaButtonReceiver(audioPendingIntent); + setSessionToken(mediaSession.getSessionToken()); + + try { + mMediaNotificationManager = new MediaNotificationManager(this); + } catch (RemoteException e) { + throw new IllegalStateException("Could not create a MediaNotificationManager", e); + } + + mediaSession.setPlaybackState(new PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_NONE, 0, 0) + .setActions(getAvailableActions()) + .build()); + + audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + public boolean isAudioStarted() { + return (audioShare != null) && (audioFiles != null) && (audioFile != null); + } + + public void startAudio(ServerShare audioShare, List audioFiles, ServerFile audioFile) { + this.audioShare = audioShare; + this.audioFiles = audioFiles; + this.audioFile = audioFile; + + setUpAudioPlayback(); + setUpAudioMetadata(); + } + + private void setUpAudioPlayback() { + try { + audioPlayer.setDataSource(getAudioUri().toString()); + audioPlayer.prepareAsync(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private Uri getAudioUri() { + return serverClient.getFileUri(audioShare, audioFile); + } + + @Override + public void onPrepared(MediaPlayer audioPlayer) { + if (audioMetadataFormatter == null) { + // Temporarily display empty audio metadata (to build notification) + BusProvider.getBus().post(new AudioMetadataRetrievedEvent(null, audioFile)); + } + BusProvider.getBus().post(new AudioPreparedEvent()); + playAudio(); + } + + private void setUpAudioMetadata() { + // Clear any previous metadata + tearDownAudioMetadataFormatter(); + // Start fetching new metadata in the background + AudioMetadataRetrievingTask.execute(getAudioUri(), audioFile); + } + + @Subscribe + public void onAudioMetadataRetrieved(AudioMetadataRetrievedEvent event) { + if (audioFile != null && audioFile == event.getServerFile()) { + this.audioMetadataFormatter = new AudioMetadataFormatter( + event.getAudioTitle(), event.getAudioArtist(), event.getAudioAlbum()); + this.audioMetadataFormatter.setDuration(event.getDuration()); + this.audioAlbumArt = event.getAudioAlbumArt(); + + setUpAudioPlayerRemote(audioMetadataFormatter, audioAlbumArt); + + mMediaNotificationManager.startNotification(); + } + } + + private void setUpAudioPlayerRemote(AudioMetadataFormatter audioMetadataFormatter, Bitmap audioAlbumArt) { + + mediaSession.setMetadata(new MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, audioMetadataFormatter.getAudioTitle(audioFile)) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, audioMetadataFormatter.getAudioSubtitle(audioShare)) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, audioMetadataFormatter.getAudioSubtitle(audioShare)) + .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, getAudioPlayerRemoteArtwork(audioAlbumArt)) + .build()); + } + + private Bitmap getAudioPlayerRemoteArtwork(Bitmap audioAlbumArt) { + if (audioAlbumArt == null) { + return null; + } + + Bitmap.Config artworkConfig = audioAlbumArt.getConfig(); + + if (artworkConfig == null) { + artworkConfig = Bitmap.Config.ARGB_8888; + } + + return audioAlbumArt.copy(artworkConfig, false); + } + + public PendingIntent createContentIntent() { + Intent audioIntent = Intents.Builder.with(this).buildServerFileIntent(audioShare, audioFiles, audioFile); + return PendingIntent.getActivity(this, 0, audioIntent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + public ServerFile getAudioFile() { + return audioFile; + } + + public AudioMetadataFormatter getAudioMetadataFormatter() { + return audioMetadataFormatter; + } + + public Bitmap getAudioAlbumArt() { + return audioAlbumArt; + } + + public MediaPlayer getAudioPlayer() { + return audioPlayer; + } + + @Subscribe + public void onAudioControlPlayPause(AudioControlPlayPauseEvent event) { + if (audioPlayer.isPlaying()) { + pauseAudio(); + } else { + playAudio(); + } + } + + @Subscribe + public void onAudioControlPlay(AudioControlPlayEvent event) { + playAudio(); + } + + @Subscribe + public void onAudioControlPause(AudioControlPauseEvent event) { + pauseAudio(); + } + + @Subscribe + public void onAudioControlNext(AudioControlNextEvent event) { + startNextAudio(); + } + + @Subscribe + public void onAudioControlPrevious(AudioControlPreviousEvent event) { + startPreviousAudio(); + } + + public void playAudio() { + mediaSession.setActive(true); + audioPlayer.start(); + setMediaPlaybackState(PlaybackStateCompat.STATE_PLAYING); + } + + public void pauseAudio() { + mediaSession.setActive(false); + audioPlayer.pause(); + setMediaPlaybackState(PlaybackStateCompat.STATE_PAUSED); + } + + private void setMediaPlaybackState(int state) { + PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder(); + playbackStateBuilder.setActions(getAvailableActions()); + playbackStateBuilder.setState(state, audioPlayer.getCurrentPosition(), 1.0f, SystemClock.elapsedRealtime()); + mediaSession.setPlaybackState(playbackStateBuilder.build()); + } + + private long getAvailableActions() { + long actions = PlaybackStateCompat.ACTION_PLAY_PAUSE | + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS | + PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + if (audioPlayer.isPlaying()) { + actions |= PlaybackStateCompat.ACTION_PAUSE; + } else { + actions |= PlaybackStateCompat.ACTION_PLAY; + } + return actions; + } + + private void startNextAudio() { + this.audioFile = getNextAudioFile(); + + tearDownAudioPlayback(); + + setUpAudioPlayback(); + setUpAudioMetadata(); + } + + private ServerFile getNextAudioFile() { + int currentAudioFilePosition = audioFiles.indexOf(audioFile); + + if (currentAudioFilePosition == audioFiles.size() - 1) { + return audioFiles.get(0); + } + + return audioFiles.get(currentAudioFilePosition + 1); + } + + private void tearDownAudioPlayback() { + if (isAudioPlaying()) + pauseAudio(); + audioPlayer.reset(); + } + + private void startPreviousAudio() { + this.audioFile = getPreviousAudioFile(); + + tearDownAudioPlayback(); + + setUpAudioPlayback(); + setUpAudioMetadata(); + } + + private ServerFile getPreviousAudioFile() { + int currentAudioFilePosition = audioFiles.indexOf(audioFile); + + if (currentAudioFilePosition == 0) { + return audioFiles.get(audioFiles.size() - 1); + } + + return audioFiles.get(currentAudioFilePosition - 1); + } + + @Override + public void onAudioFocusChange(int audioFocus) { + switch (audioFocus) { + case AudioManager.AUDIOFOCUS_GAIN: + handleAudioFocusGain(); + break; + + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + handleAudioFocusLoss(); + break; + + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + handleAudioFocusDuck(); + break; + + default: + break; + } + } + + private void handleAudioFocusGain() { + if (isAudioPlaying()) { + setUpAudioVolume(); + } else { + if (audioFocus == AudioFocus.LOSS) { + playAudio(); + } + } + + this.audioFocus = AudioFocus.GAIN; + } + + private boolean isAudioPlaying() { + try { + return isAudioStarted() && audioPlayer.isPlaying(); + } catch (IllegalStateException e) { + return false; + } + } + + private void setUpAudioVolume() { + audioPlayer.setVolume(1.0f, 1.0f); + } + + private void handleAudioFocusLoss() { + if (isAudioPlaying()) { + pauseAudio(); + this.audioFocus = AudioFocus.LOSS; + } + } + + private void handleAudioFocusDuck() { + if (isAudioPlaying()) { + tearDownAudioVolume(); + } + } + + private void tearDownAudioVolume() { + audioPlayer.setVolume(0.3f, 0.3f); + } + + @Override + public void onCompletion(MediaPlayer audioPlayer) { + BusProvider.getBus().post(new AudioCompletedEvent()); + tearDownAudioMetadataFormatter(); + startNextAudio(); + } + + @Override + public boolean onError(MediaPlayer audioPlayer, int errorReason, int errorExtra) { + getAudioPlayer().reset(); + tearDownAudioMetadataFormatter(); + return true; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + tearDownBus(); + + tearDownAudioPlayer(); + tearDownAudioPlayerRemote(); + tearDownAudioPlayerNotification(); + } + + private void tearDownAudioMetadataFormatter() { + audioMetadataFormatter = null; + } + + private void tearDownBus() { + BusProvider.getBus().unregister(this); + } + + private void tearDownAudioPlayer() { + audioPlayer.reset(); + audioPlayer.release(); + } + + private void tearDownAudioPlayerRemote() { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + + audioManager.abandonAudioFocus(this); + mediaSession.release(); + } + + private void tearDownAudioPlayerNotification() { + mMediaNotificationManager.stopNotification(); + } + + private enum AudioFocus { + GAIN, LOSS + } + + public static final class AudioServiceBinder extends Binder { + private final AudioService audioService; + + public AudioServiceBinder(AudioService audioService) { + this.audioService = audioService; + } + + public AudioService getAudioService() { + return audioService; + } + } + + private class MediaSessionCallback extends MediaSessionCompat.Callback { + @Override + public void onPlay() { + playAudio(); + } + + @Override + public void onPause() { + pauseAudio(); + } + + @Override + public void onSkipToNext() { + startNextAudio(); + } + + @Override + public void onSkipToPrevious() { + startPreviousAudio(); + } + } } diff --git a/src/main/java/org/amahi/anywhere/service/UploadService.java b/src/main/java/org/amahi/anywhere/service/UploadService.java new file mode 100644 index 000000000..86f6d3874 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/service/UploadService.java @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.service; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.provider.MediaStore; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.NotificationCompat; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerConnectedEvent; +import org.amahi.anywhere.bus.ServerConnectionChangedEvent; +import org.amahi.anywhere.db.UploadQueueDbHelper; +import org.amahi.anywhere.job.NetConnectivityJob; +import org.amahi.anywhere.model.UploadFile; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.util.NetworkUtils; +import org.amahi.anywhere.util.UploadManager; + +import java.util.ArrayList; + +import javax.inject.Inject; + +/** + * File upload service + */ +public class UploadService extends Service implements UploadManager.UploadCallbacks { + + @Inject + ServerClient serverClient; + + private UploadManager uploadManager; + private UploadQueueDbHelper uploadQueueDbHelper; + private NotificationCompat.Builder notificationBuilder; + private NetworkUtils networkUtils; + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + setUpInjections(); + setUpBus(); + setUpDbHelper(); + setUpNetworkUtils(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpBus() { + BusProvider.getBus().register(this); + } + + private void setUpDbHelper() { + uploadQueueDbHelper = UploadQueueDbHelper.init(this); + } + + private void setUpNetworkUtils() { + networkUtils = new NetworkUtils(this); + } + + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + + if (intent != null && intent.hasExtra(Intents.Extras.IMAGE_URIS)) { + if (isAutoUploadEnabled()) { + ArrayList uris = intent.getParcelableArrayListExtra(Intents.Extras.IMAGE_URIS); + for (Uri uri : uris) { + String imagePath = queryImagePath(uri); + if (imagePath != null) { + UploadFile uploadFile = uploadQueueDbHelper.addNewImagePath(imagePath); + if (uploadFile != null && uploadManager != null) + uploadManager.add(uploadFile); + } + } + } + } + + if (isAutoUploadEnabled()) { + if (isUploadAllowed()) { + connectToServer(); + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + NetConnectivityJob.scheduleJob(this); + } + } + } + + return super.onStartCommand(intent, flags, startId); + } + + private boolean isAutoUploadEnabled() { + return PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(getString(R.string.preference_key_upload_switch), false); + } + + private boolean isUploadAllowed() { + return networkUtils.isUploadAllowed(); + } + + private void connectToServer() { + Server server = getUploadServer(); + if (server != null) { + setUpServerConnection(server); + } + } + + private void setUpServerConnection(@NonNull Server server) { + if (serverClient.isConnected(server)) { + setUpServerConnection(); + } else { + serverClient.connect(this, server); + } + } + + @Subscribe + public void onServerConnected(ServerConnectedEvent event) { + Server uploadServer = getUploadServer(); + if (uploadServer != null && uploadServer == event.getServer()) { + setUpServerConnection(); + } + } + + private void setUpServerConnection() { + if (!isConnectionAvailable() || isConnectionAuto()) { + serverClient.connectAuto(); + return; + } + + if (isConnectionLocal()) { + serverClient.connectLocal(); + } else { + serverClient.connectRemote(); + } + } + + private boolean isConnectionAvailable() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + + return preferences.contains(getString(R.string.preference_key_server_connection)); + } + + private boolean isConnectionAuto() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); + + return preferenceConnection.equals(getString(R.string.preference_key_server_connection_auto)); + } + + private boolean isConnectionLocal() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String preferenceConnection = preferences.getString(getString(R.string.preference_key_server_connection), null); + + return preferenceConnection.equals(getString(R.string.preference_key_server_connection_local)); + } + + @Subscribe + public void onServerConnectionChanged(ServerConnectionChangedEvent event) { + if (uploadManager == null) { + setUpUploadManager(); + } + uploadManager.startUploading(); + } + + private Server getUploadServer() { + String session = PreferenceManager.getDefaultSharedPreferences(this) + .getString(getString(R.string.preference_key_upload_hda), null); + if (session != null) { + return new Server(session); + } else { + return null; + } + } + + private void setUpUploadManager() { + ArrayList uploadFiles = uploadQueueDbHelper.getAllImagePaths(); + uploadManager = new UploadManager(this, uploadFiles); + } + + private String queryImagePath(Uri imageUri) { + String filePath = null; + if ("content".equals(imageUri.getScheme())) { + Cursor cursor = this.getContentResolver() + .query(imageUri, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + int columnIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA); + filePath = cursor.getString(columnIndex); + cursor.close(); + } + } else { + filePath = imageUri.toString(); + } + return filePath; + } + + @Override + public void uploadStarted(int id, String fileName) { + notificationBuilder = new NotificationCompat.Builder(getApplicationContext()); + notificationBuilder + .setOngoing(true) + .setSmallIcon(R.drawable.ic_app_logo) + .setContentTitle(getString(R.string.notification_upload_title)) + .setContentText(getString(R.string.notification_upload_message, fileName)) + .setProgress(100, 0, false) + .build(); + Notification notification = notificationBuilder.build(); + startForeground(id, notification); + } + + @Override + public void uploadProgress(int id, int progress) { + NotificationManager notificationManager = (NotificationManager) getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + notificationBuilder + .setProgress(100, progress, false); + Notification notification = notificationBuilder.build(); + notificationManager.notify(id, notification); + } + + @Override + public void uploadSuccess(int id) { + uploadComplete(id, getString(R.string.message_upload_success)); + } + + @Override + public void uploadError(int id) { + uploadComplete(id, getString(R.string.message_upload_error)); + } + + private void uploadComplete(int id, String title) { + stopForeground(false); + NotificationManager notificationManager = (NotificationManager) getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + + notificationBuilder + .setContentTitle(title) + .setOngoing(false) + .setProgress(0, 0, false); + + Notification notification = notificationBuilder.build(); + notificationManager.notify(id, notification); + } + + @Override + public void removeFileFromDb(int id) { + uploadQueueDbHelper.removeImagePath(id); + } + + @Override + public void uploadQueueFinished() { + tearDownUploadManager(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + uploadQueueDbHelper.closeDataBase(); + tearDownUploadManager(); + tearDownBus(); + } + + private void tearDownUploadManager() { + if (uploadManager != null) { + uploadManager.tearDownBus(); + uploadManager = null; + } + } + + public void tearDownBus() { + BusProvider.getBus().unregister(this); + } +} diff --git a/src/main/java/org/amahi/anywhere/service/VideoService.java b/src/main/java/org/amahi/anywhere/service/VideoService.java index fcb3c936a..a5b16bc01 100644 --- a/src/main/java/org/amahi/anywhere/service/VideoService.java +++ b/src/main/java/org/amahi/anywhere/service/VideoService.java @@ -25,121 +25,165 @@ import android.os.Binder; import android.os.IBinder; +import com.squareup.otto.Subscribe; + import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerFilesLoadedEvent; import org.amahi.anywhere.server.client.ServerClient; import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.util.Mimes; import org.videolan.libvlc.LibVLC; import org.videolan.libvlc.Media; import org.videolan.libvlc.MediaPlayer; import java.util.ArrayList; +import java.util.List; import javax.inject.Inject; /** * Video service. Does all the work related to the video playback. */ -public class VideoService extends Service -{ - private ServerShare videoShare; - private ServerFile videoFile; - - private LibVLC mLibVLC; - private MediaPlayer mMediaPlayer = null; - - @Inject - ServerClient serverClient; - - @Override - public IBinder onBind(Intent intent) { - return new VideoServiceBinder(this); - } - - @Override - public void onCreate() { - super.onCreate(); - - setUpInjections(); - - setUpVideoPlayer(); - } - - private void setUpInjections() { - AmahiApplication.from(this).inject(this); - } - - private void setUpVideoPlayer() { - final ArrayList args = new ArrayList<>(); - args.add("-vvv"); - mLibVLC = new LibVLC(this, args); - mMediaPlayer = new MediaPlayer(mLibVLC); - } - - public boolean isVideoStarted() { - return (videoShare != null) && (videoFile != null); - } - - public void startVideo(ServerShare videoShare, ServerFile videoFile) { - this.videoShare = videoShare; - this.videoFile = videoFile; - - setUpVideoPlayback(); - } - - private void setUpVideoPlayback() { - Media media = new Media(mLibVLC, getVideoUri()); - mMediaPlayer.setMedia(media); - media.release(); - mMediaPlayer.play(); - } - - private Uri getVideoUri() { - return serverClient.getFileUri(videoShare, videoFile); - } - - public MediaPlayer getMediaPlayer() { - return mMediaPlayer; - } - - public boolean isVideoPlaying() { - return mMediaPlayer.isPlaying(); - } - - public void playVideo() { - mMediaPlayer.play(); - } - - public void pauseVideo() { - mMediaPlayer.pause(); - } - - - @Override - public void onDestroy() { - super.onDestroy(); - - tearDownVideoPlayback(); - } - - - private void tearDownVideoPlayback() { - mMediaPlayer.stop(); - mMediaPlayer.release(); - mLibVLC.release(); - } - - - public static final class VideoServiceBinder extends Binder - { - private final VideoService videoService; - - VideoServiceBinder(VideoService videoService) { - this.videoService = videoService; - } - - public VideoService getVideoService() { - return videoService; - } - } +public class VideoService extends Service { + @Inject + ServerClient serverClient; + private ServerShare videoShare; + private ServerFile videoFile; + private LibVLC mLibVLC; + private MediaPlayer mMediaPlayer = null; + + @Override + public IBinder onBind(Intent intent) { + return new VideoServiceBinder(this); + } + + @Override + public void onCreate() { + super.onCreate(); + + setUpInjections(); + + setUpVideoPlayer(); + + BusProvider.getBus().register(this); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setUpVideoPlayer() { + final ArrayList args = new ArrayList<>(); + args.add("-vvv"); + mLibVLC = new LibVLC(this, args); + mMediaPlayer = new MediaPlayer(mLibVLC); + } + + public boolean isVideoStarted() { + return (videoShare != null) && (videoFile != null); + } + + public void startVideo(ServerShare videoShare, ServerFile videoFile, boolean isSubtitleEnabled) { + this.videoShare = videoShare; + this.videoFile = videoFile; + + setUpVideoPlayback(isSubtitleEnabled); + } + + private void setUpVideoPlayback(boolean isSubtitleEnabled) { + Media media = new Media(mLibVLC, getVideoUri()); + mMediaPlayer.setMedia(media); + media.release(); + if (isSubtitleEnabled) { + searchSubtitleFile(); + } + mMediaPlayer.play(); + } + + private Uri getVideoUri() { + return serverClient.getFileUri(videoShare, videoFile); + } + + private void searchSubtitleFile() { + if (serverClient.isConnected()) { + if (!isDirectoryAvailable()) { + serverClient.getFiles(videoShare); + } else { + serverClient.getFiles(videoShare, getDirectory()); + } + } + } + + @Subscribe + public void onFilesLoaded(ServerFilesLoadedEvent event) { + List files = event.getServerFiles(); + for (ServerFile file : files) { + if (videoFile.getNameOnly().equals(file.getNameOnly())) { + if (Mimes.match(file.getMime()) == Mimes.Type.SUBTITLE) { + mMediaPlayer.getMedia().addSlave( + new Media.Slave( + Media.Slave.Type.Subtitle, 4, getSubtitleUri(file))); + break; + } + } + } + } + + private String getSubtitleUri(ServerFile file) { + return serverClient.getFileUri(videoShare, file).toString(); + } + + private boolean isDirectoryAvailable() { + return getDirectory() != null; + } + + private ServerFile getDirectory() { + return videoFile.getParentFile(); + } + + public MediaPlayer getMediaPlayer() { + return mMediaPlayer; + } + + public boolean isVideoPlaying() { + return mMediaPlayer.isPlaying(); + } + + public void playVideo() { + mMediaPlayer.play(); + } + + public void pauseVideo() { + mMediaPlayer.pause(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + tearDownVideoPlayback(); + BusProvider.getBus().unregister(this); + } + + + private void tearDownVideoPlayback() { + mMediaPlayer.stop(); + mMediaPlayer.release(); + mLibVLC.release(); + } + + + public static final class VideoServiceBinder extends Binder { + private final VideoService videoService; + + VideoServiceBinder(VideoService videoService) { + this.videoService = videoService; + } + + public VideoService getVideoService() { + return videoService; + } + } } diff --git a/src/main/java/org/amahi/anywhere/task/AudioMetadataRetrievingTask.java b/src/main/java/org/amahi/anywhere/task/AudioMetadataRetrievingTask.java index 893d4e495..83bd8d98e 100644 --- a/src/main/java/org/amahi/anywhere/task/AudioMetadataRetrievingTask.java +++ b/src/main/java/org/amahi/anywhere/task/AudioMetadataRetrievingTask.java @@ -28,6 +28,8 @@ import org.amahi.anywhere.bus.AudioMetadataRetrievedEvent; import org.amahi.anywhere.bus.BusEvent; import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; import java.util.HashMap; @@ -37,13 +39,21 @@ */ public class AudioMetadataRetrievingTask extends AsyncTask { private final Uri audioUri; + private final MainTVPresenter.ViewHolder viewHolder; + private final ServerFile serverFile; - private AudioMetadataRetrievingTask(Uri audioUri) { + private AudioMetadataRetrievingTask(Uri audioUri, ServerFile serverFile, MainTVPresenter.ViewHolder viewHolder) { this.audioUri = audioUri; + this.viewHolder = viewHolder; + this.serverFile = serverFile; } - public static void execute(Uri audioUri) { - new AudioMetadataRetrievingTask(audioUri).execute(); + public static void execute(Uri audioUri, ServerFile serverFile) { + new AudioMetadataRetrievingTask(audioUri, serverFile, null).execute(); + } + + public static void execute(Uri audioUri, ServerFile serverFile, MainTVPresenter.ViewHolder viewHolder) { + new AudioMetadataRetrievingTask(audioUri, serverFile, viewHolder).execute(); } @Override @@ -56,11 +66,13 @@ protected BusEvent doInBackground(Void... parameters) { String audioTitle = audioMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE); String audioArtist = audioMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST); String audioAlbum = audioMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM); + String duration = audioMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); Bitmap audioAlbumArt = extractAlbumArt(audioMetadataRetriever); - return new AudioMetadataRetrievedEvent(audioTitle, audioArtist, audioAlbum, audioAlbumArt); + return new AudioMetadataRetrievedEvent(audioTitle, audioArtist, audioAlbum, + duration, audioAlbumArt, viewHolder, serverFile); } catch (RuntimeException e) { - return new AudioMetadataRetrievedEvent(null, null, null, null); + return new AudioMetadataRetrievedEvent(viewHolder, serverFile); } finally { audioMetadataRetriever.release(); } diff --git a/src/main/java/org/amahi/anywhere/task/FileMetadataRetrievingTask.java b/src/main/java/org/amahi/anywhere/task/FileMetadataRetrievingTask.java index 338cc67cb..a8686bc60 100644 --- a/src/main/java/org/amahi/anywhere/task/FileMetadataRetrievingTask.java +++ b/src/main/java/org/amahi/anywhere/task/FileMetadataRetrievingTask.java @@ -29,6 +29,7 @@ import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerFileMetadata; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; import java.lang.ref.Reference; import java.lang.ref.WeakReference; @@ -44,6 +45,7 @@ public class FileMetadataRetrievingTask implements Callback private final ServerShare share; private final ServerFile file; private final ServerClient serverClient; + private MainTVPresenter.ViewHolder viewHolder; public FileMetadataRetrievingTask(ServerClient serverClient, View fileView) { this.serverClient = serverClient; @@ -53,6 +55,15 @@ public FileMetadataRetrievingTask(ServerClient serverClient, View fileView) { this.file = (ServerFile) fileView.getTag(ServerFilesMetadataAdapter.Tags.FILE); } + public FileMetadataRetrievingTask(ServerClient serverClient, View fileView, MainTVPresenter.ViewHolder viewHolder) { + this.serverClient = serverClient; + this.fileViewReference = new WeakReference<>(fileView); + + this.share = (ServerShare) fileView.getTag(ServerFilesMetadataAdapter.Tags.SHARE); + this.file = (ServerFile) fileView.getTag(ServerFilesMetadataAdapter.Tags.FILE); + this.viewHolder = viewHolder; + } + public void execute() { serverClient.getFileMetadata(share, file, this); } @@ -70,7 +81,7 @@ public void onResponse(Call call, Response. + */ + +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.os.Bundle; +import android.support.annotation.Nullable; + +import org.amahi.anywhere.R; + +public class IntroActivity extends Activity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_intro); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/activity/MainTVActivity.java b/src/main/java/org/amahi/anywhere/tv/activity/MainTVActivity.java new file mode 100644 index 000000000..bef52b157 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/activity/MainTVActivity.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.activity.NavigationActivity; +import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.tv.fragment.MainTVFragment; + +import java.util.ArrayList; + +public class MainTVActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main_tv); + checkAndLaunch(); + } + + private void checkAndLaunch() { + if (getServers() != null) { + replaceFragment(); + } else { + launchNav(); + } + } + + private ArrayList getServers() { + return getIntent().getParcelableArrayListExtra(getString(R.string.intent_servers)); + } + + private void replaceFragment() { + getFragmentManager().beginTransaction().add(R.id.main_tv_fragment_container, new MainTVFragment()).commit(); + } + + private void launchNav() { + startActivity(new Intent(this, NavigationActivity.class)); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + startHomeIntent(); + } + + private void startHomeIntent() { + Intent homeIntent = new Intent(Intent.ACTION_MAIN); + homeIntent.addCategory(Intent.CATEGORY_HOME); + homeIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(homeIntent); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/activity/ServerFileTvActivity.java b/src/main/java/org/amahi/anywhere/tv/activity/ServerFileTvActivity.java new file mode 100644 index 000000000..3d6b0707e --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/activity/ServerFileTvActivity.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.FileOpeningEvent; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.util.Fragments; +import org.amahi.anywhere.util.Intents; + +import java.util.List; + +public class ServerFileTvActivity extends Activity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_server_file_tv); + setFirstFragment(); + } + + private void setFirstFragment() { + getFragmentManager().beginTransaction().add(R.id.server_file_tv_container, Fragments.Builder.buildFirstTvFragment(getServerFile(), getServerShare())).commit(); + } + + private ServerFile getServerFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private ServerShare getServerShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + @Subscribe + public void onFileOpening(FileOpeningEvent event) { + setUpFile(event.getShare(), event.getFiles(), event.getFile()); + } + + private void setUpFile(ServerShare share, List files, ServerFile file) { + setUpFileActivity(share, files, file); + } + + private void setUpFileActivity(ServerShare share, List files, ServerFile file) { + if (Intents.Builder.with(this).isServerFileSupported(file)) { + startFileActivity(share, files, file); + } + } + + private void startFileActivity(ServerShare share, List files, ServerFile file) { + Intent intent = Intents.Builder.with(this).buildServerFileIntent(share, files, file); + startActivity(intent); + } + + @Override + protected void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + protected void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/activity/SettingsActivity.java b/src/main/java/org/amahi/anywhere/tv/activity/SettingsActivity.java new file mode 100644 index 000000000..415c36bbd --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/activity/SettingsActivity.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v17.leanback.app.GuidedStepFragment; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.tv.fragment.ConnectionFragment; +import org.amahi.anywhere.tv.fragment.ServerSelectFragment; +import org.amahi.anywhere.tv.fragment.SignOutFragment; +import org.amahi.anywhere.tv.fragment.ThemeFragment; + +public class SettingsActivity extends Activity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String settingsType = getIntent().getStringExtra(Intent.EXTRA_TEXT); + + if (settingsType.matches(getString(R.string.pref_title_server_select))) + buildServerSettingsFragment(); + + if (settingsType.matches(getString(R.string.pref_title_sign_out))) + buildSignOutSettingsFragment(); + + if (settingsType.matches(getString(R.string.pref_title_connection))) + buildConnectionSettingsFragment(); + + if (settingsType.matches(getString(R.string.pref_title_select_theme))) + buildSelectThemeSettingsFragment(); + } + + private void buildServerSettingsFragment() { + GuidedStepFragment.add(getFragmentManager(), new ServerSelectFragment(this)); + } + + private void buildSignOutSettingsFragment() { + GuidedStepFragment.add(getFragmentManager(), new SignOutFragment(this)); + } + + private void buildConnectionSettingsFragment() { + GuidedStepFragment.add(getFragmentManager(), new ConnectionFragment(this)); + } + + private void buildSelectThemeSettingsFragment() { + GuidedStepFragment.add(getFragmentManager(), new ThemeFragment(this)); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + finish(); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/activity/TVWebViewActivity.java b/src/main/java/org/amahi/anywhere/tv/activity/TVWebViewActivity.java new file mode 100644 index 000000000..15ac1da2e --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/activity/TVWebViewActivity.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.webkit.WebView; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.util.Intents; + +import javax.inject.Inject; + +/** + * Web activity. Shows web resources such as SVG and HTML files. + * Backed up by {@link android.webkit.WebView}. + */ + +public class TVWebViewActivity extends Activity { + @Inject + ServerClient serverClient; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_tv_web); + setUpInjections(); + setWebPage(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void setWebPage() { + WebView webView = (WebView) findViewById(R.id.tv_web_view); + webView.loadUrl(String.valueOf(getWebResourceUri())); + } + + private Uri getWebResourceUri() { + return serverClient.getFileUri(getShare(), getFile()); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private ServerFile getFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackAudioActivity.java b/src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackAudioActivity.java new file mode 100644 index 000000000..3244d1682 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackAudioActivity.java @@ -0,0 +1,81 @@ +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.app.Fragment; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.view.KeyEvent; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.fragment.TvPlaybackAudioFragment; +import org.amahi.anywhere.util.Intents; + +import java.util.ArrayList; + +import javax.inject.Inject; + +import static org.amahi.anywhere.util.Fragments.Builder.buildAudioFragment; + +public class TvPlaybackAudioActivity extends Activity { + + @Inject + ServerClient serverClient; + + Fragment fragment; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_tv_audio_playback); + setUpInjections(); + fragment = buildAudioFragment(getFile(), getShare(), getFiles()); + replaceFragment(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void replaceFragment() { + getFragmentManager().beginTransaction().replace(R.id.playback_controls_fragment_container, fragment).commit(); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private ServerFile getFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private ArrayList getFiles() { + return getIntent().getParcelableArrayListExtra(Intents.Extras.SERVER_FILES); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + PlaybackControlsRow.PlayPauseAction playPauseAction = ((TvPlaybackAudioFragment) fragment).getmPlayPauseAction(); + ((TvPlaybackAudioFragment) fragment).togglePlayPause(playPauseAction.getIndex() == PlaybackControlsRow.PlayPauseAction.PAUSE); + break; + + case KeyEvent.KEYCODE_MEDIA_REWIND: + ((TvPlaybackAudioFragment) fragment).rewind(); + break; + + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + ((TvPlaybackAudioFragment) fragment).fastForward(); + break; + } + return super.onKeyDown(keyCode, event); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackVideoActivity.java b/src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackVideoActivity.java new file mode 100644 index 000000000..3a2ec19f3 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/activity/TvPlaybackVideoActivity.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.activity; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.DialogInterface; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.view.KeyEvent; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.fragment.TvPlaybackVideoFragment; +import org.amahi.anywhere.util.Intents; + +import java.util.ArrayList; + +import javax.inject.Inject; + +import static org.amahi.anywhere.util.Fragments.Builder.buildVideoFragment; + +public class TvPlaybackVideoActivity extends Activity { + + @Inject + ServerClient serverClient; + + private Fragment fragment; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_tv_video_playback); + setUpInjections(); + fragment = buildVideoFragment(getFile(), getShare(), getFiles()); + replaceFragment(); + } + + private void setUpInjections() { + AmahiApplication.from(this).inject(this); + } + + private void replaceFragment() { + getFragmentManager().beginTransaction().replace(R.id.playback_controls_fragment_container, fragment).commit(); + } + + private ServerShare getShare() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_SHARE); + } + + private ServerFile getFile() { + return getIntent().getParcelableExtra(Intents.Extras.SERVER_FILE); + } + + private ArrayList getFiles() { + return getIntent().getParcelableArrayListExtra(Intents.Extras.SERVER_FILES); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_LEFT: + Object savedState = ((TvPlaybackVideoFragment) fragment).getmSavedState(); + if (savedState instanceof PlaybackControlsRow.RewindAction) + ((TvPlaybackVideoFragment) fragment).rewind(); + break; + + case KeyEvent.KEYCODE_DPAD_RIGHT: + savedState = ((TvPlaybackVideoFragment) fragment).getmSavedState(); + if (savedState instanceof PlaybackControlsRow.FastForwardAction) + ((TvPlaybackVideoFragment) fragment).fastForward(); + break; + + case KeyEvent.KEYCODE_MEDIA_REWIND: + ((TvPlaybackVideoFragment) fragment).rewind(); + break; + + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + playPause(); + break; + + default: + break; + } + return super.onKeyDown(keyCode, event); + } + + private void playPause() { + PlaybackControlsRow.PlayPauseAction pauseAction = ((TvPlaybackVideoFragment) fragment).getmPlayPauseAction(); + ((TvPlaybackVideoFragment) fragment).togglePlayPause(pauseAction.getIndex() == PlaybackControlsRow.PlayPauseAction.PAUSE); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Override + public void onBackPressed() { + AlertDialog.Builder builder; + builder = new AlertDialog.Builder(this, android.R.style.Theme_Material_Dialog_Alert); + + if (((TvPlaybackVideoFragment) fragment).getmPlayPauseAction().getIndex() == PlaybackControlsRow.PlayPauseAction.PAUSE) + playPause(); + + builder.setTitle(getString(R.string.exit_title)) + .setMessage(getString(R.string.exit_message)) + .setPositiveButton(getString(R.string.yes), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + TvPlaybackVideoActivity.super.onBackPressed(); + } + }) + .setNegativeButton(getString(R.string.no), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }).show(); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/ConnectionFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/ConnectionFragment.java new file mode 100644 index 000000000..3c3d7c72a --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/ConnectionFragment.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v4.content.ContextCompat; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.activity.NavigationActivity; +import org.amahi.anywhere.util.Preferences; + +import java.util.ArrayList; +import java.util.List; + +public class ConnectionFragment extends GuidedStepFragment { + + private static final int OPTION_CHECK_SET_ID = 10; + private ArrayList OPTION_NAMES = new ArrayList<>(); + private ArrayList OPTION_DESCRIPTIONS = new ArrayList<>(); + private ArrayList OPTION_CHECKED = new ArrayList<>(); + + private Context mContext; + private SharedPreferences preference; + private String initialSelected; + + public ConnectionFragment() { + } + + @SuppressLint("ValidFragment") + public ConnectionFragment(Context context) { + mContext = context; + preference = getTVPreference(); + } + + private SharedPreferences getTVPreference() { + return Preferences.getTVPreference(mContext); + } + + @NonNull + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + return new GuidanceStylist.Guidance(getString(R.string.pref_title_connection), + getString(R.string.pref_connection_desc), + "", + ContextCompat.getDrawable(getActivity(), R.drawable.ic_app_logo_shadowless)); + } + + @Override + public void onCreateActions(@NonNull List actions, Bundle savedInstanceState) { + setTitle(actions); + + populateNames(); + + populateDesc(); + + String selected = getServerConnectionStatus(); + + initialSelected = selected; + + markSelected(selected); + + setCheckedActionButtons(actions); + } + + private void setTitle(List actions) { + String title = getString(R.string.pref_title_connection); + + String desc = getString(R.string.pref_connection_desc); + + actions.add(new GuidedAction.Builder(mContext) + .title(title) + .description(desc) + .multilineDescription(true) + .infoOnly(true) + .enabled(false) + .build()); + } + + private void populateNames() { + OPTION_NAMES.add(getString(R.string.preference_entry_server_connection_auto)); + OPTION_NAMES.add(getString(R.string.preference_entry_server_connection_remote)); + OPTION_NAMES.add(getString(R.string.preference_entry_server_connection_local)); + } + + private void populateDesc() { + for (int i = 0; i < 3; i++) { + OPTION_DESCRIPTIONS.add(""); + populateChecked(); + } + } + + private void populateChecked() { + OPTION_CHECKED.add(false); + } + + private String getServerConnectionStatus() { + return Preferences.getServerConnection(preference, mContext); + } + + private void markSelected(String selected) { + if (selected.matches(getString(R.string.preference_entry_server_connection_auto))) + OPTION_CHECKED.set(0, true); + if (selected.matches(getString(R.string.preference_entry_server_connection_remote))) + OPTION_CHECKED.set(1, true); + if (selected.matches(getString(R.string.preference_entry_server_connection_local))) + OPTION_CHECKED.set(2, true); + } + + private void setCheckedActionButtons(List actions) { + for (int i = 0; i < OPTION_NAMES.size(); i++) { + addCheckedAction(actions, + + R.drawable.ic_app_logo, + + getActivity(), + + OPTION_NAMES.get(i), + + OPTION_DESCRIPTIONS.get(i), + + OPTION_CHECKED.get(i)); + } + } + + private void addCheckedAction(List actions, int iconResId, Context context, + String title, String desc, boolean checked) { + + GuidedAction guidedAction = new GuidedAction.Builder(context) + .title(title) + .description(desc) + .checkSetId(OPTION_CHECK_SET_ID) + .icon(iconResId) + .build(); + + guidedAction.setChecked(checked); + + actions.add(guidedAction); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + + if (getSelectedActionPosition() <= 3) { + if (OPTION_NAMES.get(getSelectedActionPosition() - 1).matches(getString(R.string.preference_entry_server_connection_auto))) { + Preferences.setPrefAuto(preference, mContext); + } + + if (OPTION_NAMES.get(getSelectedActionPosition() - 1).matches(getString(R.string.preference_entry_server_connection_remote))) { + Preferences.setPrefRemote(preference, mContext); + } + + if (OPTION_NAMES.get(getSelectedActionPosition() - 1).matches(getString(R.string.preference_entry_server_connection_local))) { + Preferences.setPrefLocal(preference, mContext); + } + keepCheck(); + } + } + + private void keepCheck() { + if (initialSelected.matches(Preferences.getServerConnection(preference, mContext))) + getActivity().finish(); + else + startActivity(new Intent(getActivity(), NavigationActivity.class)); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/IntroFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/IntroFragment.java new file mode 100644 index 000000000..85b132717 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/IntroFragment.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.support.annotation.Nullable; +import android.support.v17.leanback.app.OnboardingFragment; +import android.support.v4.content.ContextCompat; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.activity.NavigationActivity; + +import java.util.ArrayList; + +public class IntroFragment extends OnboardingFragment { + + private static final int[] CONTENT_IMAGES = { + R.drawable.ic_app_logo, + R.drawable.network, + R.drawable.photos, + R.drawable.music, + R.drawable.movies, + R.drawable.tick, + }; + private ArrayList mTitles, mDescriptions; + private ArrayList mColors; + + private View mBackgroundView; + + private ImageView mContentView; + + private Animator mContentAnimator; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + mTitles = new ArrayList<>(); + mDescriptions = new ArrayList<>(); + + + mColors = new ArrayList<>(); + mColors.add(ContextCompat.getColor(context, R.color.intro_1)); + mColors.add(ContextCompat.getColor(context, R.color.intro_2)); + mColors.add(Color.DKGRAY); + mColors.add(ContextCompat.getColor(context, R.color.intro_4)); + mColors.add(ContextCompat.getColor(context, R.color.intro_5)); + mColors.add(ContextCompat.getColor(context, R.color.intro_6)); + + mTitles.add(getString(R.string.app_title)); + mDescriptions.add(getString(R.string.intro_tv_1)); + + mTitles.add(getString(R.string.intro_title_2)); + mDescriptions.add(getString(R.string.intro_desc_2)); + + mTitles.add(getString(R.string.intro_title_3)); + mDescriptions.add(getString(R.string.intro_desc_tv_3)); + + mTitles.add(getString(R.string.intro_title_4)); + mDescriptions.add(getString(R.string.intro_desc_tv_4)); + + mTitles.add(getString(R.string.intro_title_5)); + mDescriptions.add(getString(R.string.intro_desc_tv_5)); + + mTitles.add(getString(R.string.intro_title_6)); + mDescriptions.add(getString(R.string.intro_desc_6)); + + setLogoResourceId(R.drawable.ic_app_logo); + } + + @Override + protected int getPageCount() { + return mTitles.size(); + } + + @Override + protected CharSequence getPageTitle(int pageIndex) { + return mTitles.get(pageIndex); + } + + @Override + protected CharSequence getPageDescription(int pageIndex) { + return mDescriptions.get(pageIndex); + } + + @Nullable + @Override + protected View onCreateBackgroundView(LayoutInflater inflater, ViewGroup container) { + mBackgroundView = inflater.inflate(R.layout.onboarding_image, container, false); + return mBackgroundView; + } + + @Nullable + @Override + protected View onCreateContentView(LayoutInflater inflater, ViewGroup container) { + mContentView = (ImageView) inflater.inflate(R.layout.onboarding_image, container, + false); + + ViewGroup.MarginLayoutParams layoutParams = ((ViewGroup.MarginLayoutParams) mContentView.getLayoutParams()); + + layoutParams.topMargin = 30; + + layoutParams.bottomMargin = 60; + + return mContentView; + } + + @Nullable + @Override + protected View onCreateForegroundView(LayoutInflater inflater, ViewGroup container) { + return null; + } + + @Override + protected Animator onCreateEnterAnimation() { + ArrayList animators = new ArrayList<>(); + + animators.add(createFadeInAnimator(mBackgroundView)); + + mContentView.setImageResource(CONTENT_IMAGES[0]); + + mContentAnimator = createFadeInAnimator(mContentView); + + animators.add(mContentAnimator); + + AnimatorSet set = new AnimatorSet(); + + set.playTogether(animators); + + mBackgroundView.setBackground(new ColorDrawable(mColors.get(0))); + + return set; + } + + @Override + protected void onPageChanged(final int newPage, int previousPage) { + if (mContentAnimator != null) { + mContentAnimator.end(); + } + + ArrayList animators = new ArrayList<>(); + + Animator fadeOut = createFadeOutAnimator(mContentView); + + fadeOut.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + Log.d(getClass().getName(), String.valueOf(newPage)); + mContentView.setImageResource(CONTENT_IMAGES[newPage]); + switch (newPage) { + case 0: + mBackgroundView.setBackground(new ColorDrawable(mColors.get(newPage))); + break; + case 1: + mBackgroundView.setBackground(new ColorDrawable(mColors.get(newPage))); + break; + case 2: + mBackgroundView.setBackground(new ColorDrawable(mColors.get(newPage))); + break; + case 3: + mBackgroundView.setBackground(new ColorDrawable(mColors.get(newPage))); + break; + case 4: + mBackgroundView.setBackground(new ColorDrawable(mColors.get(newPage))); + break; + case 5: + mBackgroundView.setBackground(new ColorDrawable(mColors.get(newPage))); + break; + } + } + }); + + animators.add(fadeOut); + + animators.add(createFadeInAnimator(mContentView)); + + AnimatorSet set = new AnimatorSet(); + + set.playSequentially(animators); + + set.start(); + + mContentAnimator = set; + } + + private Animator createFadeInAnimator(View view) { + return ObjectAnimator.ofFloat(view, View.ALPHA, 0.0f, 1.0f).setDuration(500); + } + + private Animator createFadeOutAnimator(View view) { + return ObjectAnimator.ofFloat(view, View.ALPHA, 1.0f, 0.0f).setDuration(500); + } + + @Override + protected void onFinishFragment() { + startActivity(new Intent(getActivity(), NavigationActivity.class)); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/MainTVFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/MainTVFragment.java new file mode 100644 index 000000000..51231b87f --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/MainTVFragment.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.support.v17.leanback.app.BrowseFragment; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.PresenterSelector; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.FileOpeningEvent; +import org.amahi.anywhere.bus.ServerConnectionChangedEvent; +import org.amahi.anywhere.bus.ServerFilesLoadedEvent; +import org.amahi.anywhere.bus.ServerSharesLoadFailedEvent; +import org.amahi.anywhere.bus.ServerSharesLoadedEvent; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.presenter.IconHeaderPresenter; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; +import org.amahi.anywhere.tv.presenter.SettingsItemPresenter; +import org.amahi.anywhere.util.Intents; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import javax.inject.Inject; + +public class MainTVFragment extends BrowseFragment { + + @Inject + ServerClient serverClient; + List serverShareList; + private FilesSort filesSort = FilesSort.MODIFICATION_TIME; + private ArrayObjectAdapter mRowsAdapter; + private ListRow settingsRow; + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + setUpInjections(); + + setupUIElements(); + + loadRows(); + + loadShares(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setupUIElements() { + setTitle(getString(R.string.app_title)); + + setHeadersState(HEADERS_ENABLED); + + setHeaderPresenter(); + + setHeadersTransitionOnBackEnabled(true); + + setBrandColor(Color.parseColor("#0277bd")); + + setSearchAffordanceColor(Color.GREEN); + } + + private void setHeaderPresenter() { + setHeaderPresenterSelector(new PresenterSelector() { + @Override + public Presenter getPresenter(Object item) { + return new IconHeaderPresenter(); + } + }); + } + + private void loadRows() { + mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); + addSettings(mRowsAdapter); + } + + private void loadShares() { + if (serverClient.isConnected()) { + serverClient.getShares(); + } + } + + @Subscribe + public void onServerConnectionChanged(ServerConnectionChangedEvent event) { + serverClient.getShares(); + } + + @Subscribe + public void onSharesLoaded(ServerSharesLoadedEvent event) { + List serverShareList = event.getServerShares(); + this.serverShareList = serverShareList; + for (int i = 0; i < serverShareList.size(); i++) { + serverClient.getFiles(serverShareList.get(i)); + } + } + + @Subscribe + public void onFilesLoaded(ServerFilesLoadedEvent event) { + List serverFiles = sortFiles(event.getServerFiles()); + ListRow listRow = null; + ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(new MainTVPresenter(getActivity(), serverClient, serverFiles)); + if (serverFiles.size() != 0) { + String shareName = serverFiles.get(0).getParentShare().getName(); + for (int i = 0; i < serverFiles.size(); i++) { + gridRowAdapter.add(serverFiles.get(i)); + } + for (int i = 0; i < serverShareList.size(); i++) { + if (shareName.matches(serverShareList.get(i).getName())) { + HeaderItem headerItem = new HeaderItem(shareName); + listRow = new ListRow(headerItem, gridRowAdapter); + mRowsAdapter.add(listRow); + serverShareList.remove(i); + break; + } + } + } + + if (listRow != null) { + int index1 = mRowsAdapter.indexOf(listRow); + int index2 = mRowsAdapter.indexOf(settingsRow); + mRowsAdapter.replace(index1, settingsRow); + mRowsAdapter.replace(index2, listRow); + } + sortHeaders(); + setAdapter(mRowsAdapter); + } + + private void addSettings(ArrayObjectAdapter adapter) { + ArrayObjectAdapter gridRowAdapter; + + HeaderItem settings = new HeaderItem("Settings"); + ArrayList serverArrayList = getActivity().getIntent().getParcelableArrayListExtra(getString(R.string.intent_servers)); + gridRowAdapter = new ArrayObjectAdapter(new SettingsItemPresenter(serverArrayList)); + gridRowAdapter.add(getString(R.string.pref_title_server_select)); + gridRowAdapter.add(getString(R.string.pref_title_sign_out)); + gridRowAdapter.add(getString(R.string.pref_title_connection)); +// Note - @octacode: Theme settings haven't been implemented yet. +// gridRowAdapter.add(getString(R.string.pref_title_select_theme)); + settingsRow = new ListRow(settings, gridRowAdapter); + adapter.add(0, settingsRow); + } + + private void sortHeaders() { + for (int i = 0; i < mRowsAdapter.size() - 1; i++) { + for (int j = i + 1; j < mRowsAdapter.size() - 1; j++) { + ListRow listRow1 = (ListRow) mRowsAdapter.get(i); + ListRow listRow2 = (ListRow) mRowsAdapter.get(j); + if ((int) listRow2.getHeaderItem().getName().charAt(0) < (int) listRow1.getHeaderItem().getName().charAt(0)) { + int index1 = mRowsAdapter.indexOf(listRow1); + int index2 = mRowsAdapter.indexOf(listRow2); + mRowsAdapter.replace(index1, listRow2); + mRowsAdapter.replace(index2, listRow1); + } + } + } + } + + private List sortFiles(List files) { + List sortedFiles = new ArrayList(files); + + Collections.sort(sortedFiles, getFilesComparator()); + + return sortedFiles; + } + + private Comparator getFilesComparator() { + switch (filesSort) { + case NAME: + return new FileNameComparator(); + + case MODIFICATION_TIME: + return new FileModificationTimeComparator(); + + default: + return null; + } + } + + @Subscribe + public void onSharesLoadFailed(ServerSharesLoadFailedEvent event) { + + } + + @Subscribe + public void onFileOpening(FileOpeningEvent event) { + setUpFile(event.getShare(), event.getFiles(), event.getFile()); + } + + private void setUpFile(ServerShare share, List files, ServerFile file) { + setUpFileActivity(share, files, file); + } + + private void setUpFileActivity(ServerShare share, List files, ServerFile file) { + if (Intents.Builder.with(getActivity()).isServerFileSupported(file)) { + startFileActivity(share, files, file); + } + } + + private void startFileActivity(ServerShare share, List files, ServerFile file) { + Intent intent = Intents.Builder.with(getActivity()).buildServerFileIntent(share, files, file); + startActivity(intent); + } + + @Override + public void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + private enum FilesSort { + NAME, MODIFICATION_TIME + } + + private static final class FileNameComparator implements Comparator { + @Override + public int compare(ServerFile firstFile, ServerFile secondFile) { + return firstFile.getName().compareTo(secondFile.getName()); + } + } + + private static final class FileModificationTimeComparator implements Comparator { + @Override + public int compare(ServerFile firstFile, ServerFile secondFile) { + return -firstFile.getModificationTime().compareTo(secondFile.getModificationTime()); + } + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/ServerFileTvFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/ServerFileTvFragment.java new file mode 100644 index 000000000..3cce154ab --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/ServerFileTvFragment.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.app.Fragment; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v17.leanback.app.VerticalGridFragment; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.OnItemViewClickedListener; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.Row; +import android.support.v17.leanback.widget.RowPresenter; +import android.support.v17.leanback.widget.VerticalGridPresenter; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.FileOpeningEvent; +import org.amahi.anywhere.bus.ServerFilesLoadedEvent; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; +import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.util.Mimes; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import javax.inject.Inject; + +public class ServerFileTvFragment extends VerticalGridFragment { + + @Inject + ServerClient serverClient; + + private ServerShare mServerShare; + private ServerFile mServerFile; + private ArrayObjectAdapter mAdapter; + private List mServerFileList; + private FilesSort filesSort = FilesSort.MODIFICATION_TIME; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setUpInjections(); + mServerShare = getArguments().getParcelable(Intents.Extras.SERVER_SHARE); + mServerFile = getArguments().getParcelable(Intents.Extras.SERVER_FILE); + if (mServerFile != null) + setTitle(mServerFile.getName()); + setDefaultListeners(); + setUpFragment(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setDefaultListeners() { + setOnItemViewClickedListener(getDefaultItemClickedListener()); + } + + protected OnItemViewClickedListener getDefaultItemClickedListener() { + return new OnItemViewClickedListener() { + @Override + public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { + ServerFile serverFile = (ServerFile) item; + if (isDirectory(serverFile)) { + setFragment(serverFile, serverFile.getParentShare()); + } else { + startFileOpening(serverFile); + } + } + }; + } + + private void setFragment(ServerFile serverFile, ServerShare serverShare) { + getFragmentManager().beginTransaction().replace(R.id.server_file_tv_container, buildTvFragment(serverFile, serverShare), getClass().getSimpleName()).addToBackStack(getClass().getSimpleName()).commit(); + } + + private Fragment buildTvFragment(ServerFile serverFile, ServerShare serverShare) { + Fragment fragment = new ServerFileTvFragment(); + Bundle bundle = new Bundle(); + bundle.putParcelable(Intents.Extras.SERVER_FILE, serverFile); + bundle.putParcelable(Intents.Extras.SERVER_SHARE, serverShare); + fragment.setArguments(bundle); + return fragment; + } + + private void startFileOpening(ServerFile file) { + BusProvider.getBus().post(new FileOpeningEvent(file.getParentShare(), mServerFileList, file)); + } + + private boolean isDirectory(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY; + } + + private void setUpFragment() { + VerticalGridPresenter gridPresenter = new VerticalGridPresenter(); + gridPresenter.setNumberOfColumns(4); + setGridPresenter(gridPresenter); + setContent(); + mAdapter = new ArrayObjectAdapter(new MainTVPresenter(getActivity(), serverClient)); + } + + private void setContent() { + if (serverClient.isConnected()) { + serverClient.getFiles(mServerShare, mServerFile); + } + } + + @Subscribe + public void onFilesLoaded(ServerFilesLoadedEvent event) { + mServerFileList = sortFiles(event.getServerFiles()); + for (int i = 0; i < mServerFileList.size(); i++) + mAdapter.add(mServerFileList.get(i)); + setAdapter(mAdapter); + } + + private List sortFiles(List files) { + List sortedFiles = new ArrayList<>(files); + + Collections.sort(sortedFiles, getFilesComparator()); + + return sortedFiles; + } + + private Comparator getFilesComparator() { + switch (filesSort) { + case NAME: + return new FileNameComparator(); + + case MODIFICATION_TIME: + return new FileModificationTimeComparator(); + + default: + return null; + } + } + + @Override + public void onResume() { + super.onResume(); + + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + + BusProvider.getBus().unregister(this); + } + + private enum FilesSort { + NAME, MODIFICATION_TIME + } + + private static final class FileNameComparator implements Comparator { + @Override + public int compare(ServerFile firstFile, ServerFile secondFile) { + return firstFile.getName().compareTo(secondFile.getName()); + } + } + + private static final class FileModificationTimeComparator implements Comparator { + @Override + public int compare(ServerFile firstFile, ServerFile secondFile) { + return -firstFile.getModificationTime().compareTo(secondFile.getModificationTime()); + } + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/ServerSelectFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/ServerSelectFragment.java new file mode 100644 index 000000000..0cd17b1ed --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/ServerSelectFragment.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v4.content.ContextCompat; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.activity.NavigationActivity; +import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.util.Preferences; + +import java.util.ArrayList; +import java.util.List; + +public class ServerSelectFragment extends GuidedStepFragment { + + private static final int OPTION_CHECK_SET_ID = 10; + private int indexSelected = 0; + private Context mContext; + private ArrayList mServerArrayList; + private ArrayList OPTION_NAMES = new ArrayList<>(); + private ArrayList OPTION_DESCRIPTIONS = new ArrayList<>(); + private ArrayList OPTION_CHECKED = new ArrayList<>(); + private SharedPreferences mSharedPref; + + @SuppressLint("ValidFragment") + public ServerSelectFragment(Context context) { + mContext = context; + } + + public ServerSelectFragment() { + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + } + + @NonNull + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + return new GuidanceStylist.Guidance(getString(R.string.pref_title_server_select), + getString(R.string.pref_title_server_select_desc), + "", + ContextCompat.getDrawable(getActivity(), R.drawable.ic_app_logo_shadowless)); + } + + @Override + public void onCreateActions(@NonNull List actions, Bundle savedInstanceState) { + super.onCreateActions(actions, savedInstanceState); + mSharedPref = Preferences.getPreference(mContext); + mServerArrayList = getActivity().getIntent().getParcelableArrayListExtra(getString(R.string.intent_servers)); + + setTitle(actions); + populateData(); + String serverName = Preferences.getServerFromPref(mContext, mSharedPref); + if (serverName == null) { + setDefaultChecked(); + } else { + for (int i = 0; i < mServerArrayList.size(); i++) { + if (serverName.matches(mServerArrayList.get(i).getName())) { + indexSelected = i; + break; + } + } + setFalseChecked(); + OPTION_CHECKED.set(indexSelected, true); + } + + setCheckedActionButtons(actions); + } + + private void setTitle(List actions) { + String title = getString(R.string.pref_title_server_active_list); + + String desc = getString(R.string.pref_server_active_list_desc); + + actions.add(new GuidedAction.Builder(mContext) + .title(title) + .description(desc) + .multilineDescription(true) + .infoOnly(true) + .enabled(false) + .build()); + } + + private void populateData() { + for (int i = 0; i < mServerArrayList.size(); i++) { + setName(i); + setDesc(); + } + } + + private void setName(int i) { + OPTION_NAMES.add(mServerArrayList.get(i).getName()); + } + + private void setDesc() { + OPTION_DESCRIPTIONS.add(""); + } + + private void setDefaultChecked() { + OPTION_CHECKED.add(true); + for (int i = 1; i < mServerArrayList.size(); i++) + OPTION_CHECKED.add(false); + } + + private void setFalseChecked() { + if (OPTION_CHECKED != null) { + setFalse(); + } else { + OPTION_CHECKED.clear(); + setFalse(); + } + } + + private void setFalse() { + for (int i = 0; i < mServerArrayList.size(); i++) + OPTION_CHECKED.add(false); + } + + private void setCheckedActionButtons(List actions) { + for (int i = 0; i < OPTION_NAMES.size(); i++) { + addCheckedAction(actions, + + R.drawable.ic_app_logo, + + getActivity(), + + OPTION_NAMES.get(i), + + OPTION_DESCRIPTIONS.get(i), + + OPTION_CHECKED.get(i)); + } + } + + private void addCheckedAction(List actions, int iconResId, Context context, + String title, String desc, boolean checked) { + + GuidedAction guidedAction = new GuidedAction.Builder(context) + .title(title) + .description(desc) + .checkSetId(OPTION_CHECK_SET_ID) + .icon(iconResId) + .build(); + + guidedAction.setChecked(checked); + + actions.add(guidedAction); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (getSelectedActionPosition() <= mServerArrayList.size()) { + String server = mServerArrayList.get(getSelectedActionPosition() - 1).getName(); + if (indexSelected == (getSelectedActionPosition() - 1)) + getActivity().finish(); + else { + Preferences.setServertoPref(server, mContext, mSharedPref); + startActivity(new Intent(mContext, NavigationActivity.class)); + } + } + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/SignOutFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/SignOutFragment.java new file mode 100644 index 000000000..b3fb8455b --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/SignOutFragment.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist; +import android.support.v17.leanback.widget.GuidedAction; +import android.util.Log; +import android.widget.Toast; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.account.AmahiAccount; +import org.amahi.anywhere.activity.NavigationActivity; + +import java.util.Arrays; +import java.util.List; + +public class SignOutFragment extends GuidedStepFragment implements AccountManagerCallback { + + private static final int ACTION_CONTINUE = 0; + private static final int ACTION_BACK = 1; + + private Context mContext; + + public SignOutFragment() { + } + + @SuppressLint("ValidFragment") + public SignOutFragment(Context context) { + mContext = context; + } + + @NonNull + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.pref_title_account); + + String breadcrumb = getString(R.string.pref_title_sign_out); + + String description = getString(R.string.pref_sign_out_desc); + + Drawable icon = null; + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + icon = getActivity().getDrawable(R.drawable.ic_app_logo_shadowless); + } + + return new GuidanceStylist.Guidance(title, description, breadcrumb, icon); + } + + @Override + public void onCreateActions(@NonNull List actions, Bundle savedInstanceState) { + + addAction(actions, ACTION_CONTINUE, getString(R.string.pref_title_sign_out), ""); + + addAction(actions, ACTION_BACK, getString(R.string.pref_option_go_back), ""); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + + switch ((int) action.getId()) { + case ACTION_CONTINUE: + tearDownAccount(); + break; + + case ACTION_BACK: + getActivity().finish(); + break; + + default: + Log.w(getClass().getSimpleName(), getString(R.string.pref_action_not_defined)); + break; + } + } + + private void addAction(List actions, long id, String title, String desc) { + actions.add(new GuidedAction.Builder(mContext) + .id(id) + .title(title) + .description(desc) + .build()); + } + + private void tearDownAccount() { + if (!getAccounts().isEmpty()) { + Account account = getAccounts().get(0); + + getAccountManager().removeAccount(account, this, null); + } else { + tearDownActivity(); + } + } + + private List getAccounts() { + return Arrays.asList(getAccountManager().getAccountsByType(AmahiAccount.TYPE)); + } + + private AccountManager getAccountManager() { + return AccountManager.get(getActivity()); + } + + private void tearDownActivity() { + Toast.makeText(getActivity(), R.string.message_logout, Toast.LENGTH_SHORT).show(); + + Intent myIntent = new Intent(getActivity().getApplicationContext(), NavigationActivity.class); + + myIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + + startActivity(myIntent); + + getActivity().finish(); + } + + @Override + public void run(AccountManagerFuture accountManagerFuture) { + tearDownActivity(); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/ThemeFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/ThemeFragment.java new file mode 100644 index 000000000..6c705bf16 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/ThemeFragment.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v4.content.ContextCompat; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.util.Preferences; + +import java.util.ArrayList; +import java.util.List; + +public class ThemeFragment extends GuidedStepFragment { + + private static final int OPTION_CHECK_SET_ID = 10; + private static final int ACTION_BACK = 1; + private ArrayList OPTION_NAMES = new ArrayList<>(); + private ArrayList OPTION_DESCRIPTIONS = new ArrayList<>(); + private ArrayList OPTION_CHECKED = new ArrayList<>(); + private Context mContext; + private SharedPreferences mSharedPreferences; + + public ThemeFragment() { + } + + @SuppressLint("ValidFragment") + public ThemeFragment(Context context) { + mContext = context; + } + + @NonNull + + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + return new GuidanceStylist.Guidance(getString(R.string.pref_title_select_theme), + getString(R.string.pref_theme_desc), + "", + ContextCompat.getDrawable(getActivity(), R.drawable.ic_app_logo_shadowless)); + } + + @Override + public void onCreateActions(@NonNull List actions, Bundle savedInstanceState) { + + setTitle(actions); + + setPreference(); + + setOPTION_NAMES(); + + setOptionDesc(); + + String prefCheck = mSharedPreferences.getString(getString(R.string.pref_key_theme), getString(R.string.pref_theme_dark)); + + setChecked(prefCheck); + + setCheckedActionButtons(actions); + + setBackButton(actions); + } + + private void setTitle(List actions) { + String title = getString(R.string.pref_title_theme); + + String desc = getString(R.string.pref_theme_detail_desc); + + actions.add(new GuidedAction.Builder(mContext) + .title(title) + .description(desc) + .multilineDescription(true) + .infoOnly(true) + .enabled(false) + .build()); + + } + + private void setPreference() { + mSharedPreferences = Preferences.getPreference(mContext); + } + + private void setOPTION_NAMES() { + OPTION_NAMES.add(getString(R.string.pref_theme_light)); + OPTION_NAMES.add(getString(R.string.pref_theme_dark)); + } + + private void setOptionDesc() { + for (int i = 0; i < 2; i++) { + OPTION_DESCRIPTIONS.add(""); + setOptionCheck(); + } + } + + private void setOptionCheck() { + OPTION_CHECKED.add(false); + } + + private void setChecked(String prefCheck) { + if (prefCheck.matches(getString(R.string.pref_theme_light))) OPTION_CHECKED.set(0, true); + + if (prefCheck.matches(getString(R.string.pref_theme_dark))) OPTION_CHECKED.set(1, true); + } + + private void setCheckedActionButtons(List actions) { + for (int i = 0; i < OPTION_NAMES.size(); i++) { + addCheckedAction(actions, + + R.drawable.ic_app_logo, + + getActivity(), + + OPTION_NAMES.get(i), + + OPTION_DESCRIPTIONS.get(i), + + OPTION_CHECKED.get(i)); + } + } + + private void addCheckedAction(List actions, int iconResId, Context context, + String title, String desc, boolean checked) { + + GuidedAction guidedAction = new GuidedAction.Builder(context) + .title(title) + .description(desc) + .checkSetId(OPTION_CHECK_SET_ID) + .icon(iconResId) + .build(); + + guidedAction.setChecked(checked); + + actions.add(guidedAction); + } + + private void setBackButton(List actions) { + addAction(actions, ACTION_BACK, getString(R.string.pref_option_go_back), ""); + } + + private void addAction(List actions, long id, String title, String desc) { + actions.add(new GuidedAction.Builder(mContext) + .id(id) + .title(title) + .description(desc) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + + if (getSelectedActionPosition() <= 2) { + if (OPTION_NAMES.get(getSelectedActionPosition() - 1).matches("Amahi light")) { + Preferences.setLight(mContext, mSharedPreferences); + } + + if (OPTION_NAMES.get(getSelectedActionPosition() - 1).matches("Amahi dark")) { + Preferences.setDark(mContext, mSharedPreferences); + } + } + + if (getSelectedActionPosition() == 3) { + getActivity().finish(); + } + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackAudioFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackAudioFragment.java new file mode 100644 index 000000000..6d89fe19c --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackAudioFragment.java @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.session.PlaybackState; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.RequiresApi; +import android.support.v17.leanback.app.PlaybackFragment; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.ControlButtonPresenterSelector; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.OnItemViewClickedListener; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.support.v17.leanback.widget.PlaybackControlsRowPresenter; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.Row; +import android.support.v17.leanback.widget.RowPresenter; +import android.support.v4.content.ContextCompat; +import android.widget.ImageView; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.AudioMetadataRetrievedEvent; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.task.AudioMetadataRetrievingTask; +import org.amahi.anywhere.tv.presenter.AudioDetailsDescriptionPresenter; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; +import org.amahi.anywhere.util.Fragments; +import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.util.Mimes; + +import java.io.IOException; +import java.util.ArrayList; + +import javax.inject.Inject; + +public class TvPlaybackAudioFragment extends PlaybackFragment { + + private static final int DEFAULT_UPDATE_PERIOD = 1000; + private static final int UPDATE_PERIOD = 16; + @Inject + ServerClient serverClient; + private ArrayObjectAdapter mRowsAdapter; + private ArrayObjectAdapter mPrimaryActionsAdapter; + private PlaybackControlsRow mPlaybackControlsRow; + private PlaybackControlsRow.PlayPauseAction mPlayPauseAction; + private PlaybackControlsRow.SkipNextAction mSkipNextAction; + private PlaybackControlsRow.SkipPreviousAction mSkipPreviousAction; + private PlaybackControlsRow.FastForwardAction mFastForwardAction; + private PlaybackControlsRow.RewindAction mRewindAction; + private int mCurrentPlaybackState; + private Handler mHandler; + private Runnable mRunnable; + private MediaPlayer mediaPlayer; + private ArrayList mAudioList; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setFadingEnabled(false); + + setBackgroundType(BG_DARK); + + setUpInjections(); + + mHandler = new Handler(Looper.getMainLooper()); + + setUpRows(); + + getAllAudioFiles(); + + AudioMetadataRetrievingTask.execute(getFileUri(), getAudioFile()); + + mediaPlayer = new MediaPlayer(); + + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + skipNext(); + } + }); + + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + + setDataSource(); + + prepareAudio(); + + mediaPlayer.start(); + + setOnItemViewClickedListener(new OnItemViewClickedListener() { + @Override + public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { + if (item instanceof ServerFile) { + ServerFile serverFile = (ServerFile) item; + replaceFragment(serverFile); + } + } + }); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void replaceFragment(ServerFile serverFile) { + getFragmentManager().beginTransaction().replace(R.id.playback_controls_fragment_container, Fragments.Builder.buildAudioFragment(serverFile, getAudioShare(), getAudioFiles())).commit(); + } + + private void setUpRows() { + ClassPresenterSelector ps = new ClassPresenterSelector(); + PlaybackControlsRowPresenter playbackControlsRowPresenter = new PlaybackControlsRowPresenter(new AudioDetailsDescriptionPresenter(getActivity())); + + ps.addClassPresenter(PlaybackControlsRow.class, playbackControlsRowPresenter); + ps.addClassPresenter(ListRow.class, new ListRowPresenter()); + mRowsAdapter = new ArrayObjectAdapter(ps); + playbackControlsRowPresenter.setBackgroundColor(ContextCompat.getColor(getActivity(), R.color.primary)); + playbackControlsRowPresenter.setProgressColor(Color.WHITE); + playbackControlsRowPresenter.setOnActionClickedListener(new OnActionClickedListener() { + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Override + public void onActionClicked(Action action) { + if (action.getId() == mPlayPauseAction.getId()) { + togglePlayPause(mPlayPauseAction.getIndex() == PlaybackControlsRow.PlayPauseAction.PAUSE); + } else if (action.getId() == mRewindAction.getId()) { + rewind(); + } else if (action.getId() == mFastForwardAction.getId()) { + fastForward(); + } else if (action.getId() == mSkipNextAction.getId()) { + skipNext(); + } else if (action.getId() == mSkipPreviousAction.getId()) { + skipPrevious(); + } + if (action instanceof PlaybackControlsRow.MultiAction) { + notifyChanged(action); + } + } + }); + setAdapter(mRowsAdapter); + } + + private void setDataSource() { + try { + mediaPlayer.setDataSource(getActivity(), getFileUri()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void prepareAudio() { + try { + mediaPlayer.prepare(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void togglePlayPause(boolean isPaused) { + if (isPaused) { + mediaPlayer.pause(); + } else { + mediaPlayer.start(); + } + playbackStateChanged(); + } + + public void rewind() { + if (mediaPlayer.getCurrentPosition() - (10 * 1000) > 0) { + mediaPlayer.seekTo(mediaPlayer.getCurrentPosition() - (10 * 1000)); + mPlaybackControlsRow.setCurrentTime(mediaPlayer.getCurrentPosition()); + } + } + + public void fastForward() { + if (mediaPlayer.getCurrentPosition() + (10 * 1000) <= mediaPlayer.getDuration()) { + mediaPlayer.seekTo(mediaPlayer.getCurrentPosition() + (10 * 1000)); + mPlaybackControlsRow.setCurrentTime(mediaPlayer.getCurrentPosition()); + } + } + + private void skipNext() { + int presentIndex = getAudioFiles().indexOf(getAudioFile()); + if (presentIndex < (getAudioFiles().size() - 1)) + replaceFragment(getAudioFiles().get(presentIndex + 1)); + else + replaceFragment(getAudioFiles().get(0)); + } + + private void skipPrevious() { + int presentIndex = getAudioFiles().indexOf(getAudioFile()); + if (presentIndex > 0) + replaceFragment(getAudioFiles().get(presentIndex - 1)); + else + replaceFragment(getAudioFiles().get(getAudioFiles().size() - 1)); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void playbackStateChanged() { + + if (mCurrentPlaybackState != PlaybackState.STATE_PLAYING) { + mCurrentPlaybackState = PlaybackState.STATE_PLAYING; + startProgressAutomation(); + setFadingEnabled(false); + mPlayPauseAction.setIndex(PlaybackControlsRow.PlayPauseAction.PAUSE); + mPlayPauseAction.setIcon(mPlayPauseAction.getDrawable(PlaybackControlsRow.PlayPauseAction.PAUSE)); + notifyChanged(mPlayPauseAction); + } else { + mCurrentPlaybackState = PlaybackState.STATE_PAUSED; + stopProgressAutomation(); + mPlayPauseAction.setIndex(PlaybackControlsRow.PlayPauseAction.PLAY); + mPlayPauseAction.setIcon(mPlayPauseAction.getDrawable(PlaybackControlsRow.PlayPauseAction.PLAY)); + notifyChanged(mPlayPauseAction); + } + } + + private void startProgressAutomation() { + if (mRunnable == null) { + mRunnable = new Runnable() { + @Override + public void run() { + int updatePeriod = getUpdatePeriod(); + int currentTime = mPlaybackControlsRow.getCurrentTime() + updatePeriod; + int totalTime = mPlaybackControlsRow.getTotalTime(); + mPlaybackControlsRow.setCurrentTime(currentTime); + + if (totalTime > 0 && totalTime <= currentTime) { + stopProgressAutomation(); + } else { + mHandler.postDelayed(this, updatePeriod); + } + } + }; + mHandler.postDelayed(mRunnable, getUpdatePeriod()); + } + } + + private void stopProgressAutomation() { + if (mHandler != null && mRunnable != null) { + mHandler.removeCallbacks(mRunnable); + mRunnable = null; + } + } + + private int getUpdatePeriod() { + if (getView() == null || mPlaybackControlsRow.getTotalTime() <= 0) { + return DEFAULT_UPDATE_PERIOD; + } + return Math.max(UPDATE_PERIOD, mPlaybackControlsRow.getTotalTime() / getView().getWidth()); + } + + private void notifyChanged(Action action) { + ArrayObjectAdapter adapter = mPrimaryActionsAdapter; + if (adapter.indexOf(action) >= 0) { + adapter.notifyArrayItemRangeChanged(adapter.indexOf(action), 1); + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private void addPlaybackControlsRow(AudioMetadataRetrievedEvent event) { + mPlaybackControlsRow = new PlaybackControlsRow(event); + mRowsAdapter.add(mPlaybackControlsRow); + + ControlButtonPresenterSelector presenterSelector = new ControlButtonPresenterSelector(); + mPrimaryActionsAdapter = new ArrayObjectAdapter(presenterSelector); + mPlaybackControlsRow.setPrimaryActionsAdapter(mPrimaryActionsAdapter); + + mSkipPreviousAction = new PlaybackControlsRow.SkipPreviousAction(getActivity()); + mRewindAction = new PlaybackControlsRow.RewindAction(getActivity()); + mPlayPauseAction = new PlaybackControlsRow.PlayPauseAction(getActivity()); + mFastForwardAction = new PlaybackControlsRow.FastForwardAction(getActivity()); + mSkipNextAction = new PlaybackControlsRow.SkipNextAction(getActivity()); + mPrimaryActionsAdapter.add(mSkipPreviousAction); + mPrimaryActionsAdapter.add(mRewindAction); + mPrimaryActionsAdapter.add(mPlayPauseAction); + mPrimaryActionsAdapter.add(mFastForwardAction); + mPrimaryActionsAdapter.add(mSkipNextAction); + playbackStateChanged(); + } + + private void addOtherRows() { + ArrayObjectAdapter adapter = new ArrayObjectAdapter(new MainTVPresenter(getActivity(), serverClient, getAudioShare())); + for (ServerFile serverFile : getAudioFiles()) { + adapter.add(serverFile); + } + + HeaderItem header; + + if (getAudioFile().getParentFile() == null) + header = new HeaderItem(0, "Song(s) in " + getAudioShare().getName()); + else + header = new HeaderItem(0, "Song(s) in " + getAudioFile().getParentFile().getName()); + + mRowsAdapter.add(new ListRow(header, adapter)); + } + + private boolean isAudio(ServerFile serverFile) { + return Mimes.match(serverFile.getMime()) == Mimes.Type.AUDIO; + } + + private ServerFile getAudioFile() { + return getArguments().getParcelable(Intents.Extras.SERVER_FILE); + } + + private ArrayList getAudioFiles() { + return mAudioList; + } + + private ArrayList getAllAudioFiles() { + mAudioList = new ArrayList<>(); + ArrayList allFiles = getArguments().getParcelableArrayList(Intents.Extras.SERVER_FILES); + if (allFiles != null) { + for (ServerFile serverFile : allFiles) { + if (isAudio(serverFile)) { + mAudioList.add(serverFile); + } + } + } + return mAudioList; + } + + private ServerShare getAudioShare() { + return getArguments().getParcelable(Intents.Extras.SERVER_SHARE); + } + + private Uri getFileUri() { + return serverClient.getFileUri(getAudioShare(), getAudioFile()); + } + + private ImageView getBackground() { + return (ImageView) getActivity().findViewById(R.id.imageViewBackground); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Subscribe + public void onAudioMetadataRetrieved(AudioMetadataRetrievedEvent event) { + addPlaybackControlsRow(event); + addOtherRows(); + mediaPlayer.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener() { + @Override + public void onBufferingUpdate(MediaPlayer mp, int percent) { + int time = percent * mediaPlayer.getDuration(); + mPlaybackControlsRow.setBufferedProgress(time); + } + }); + mPlaybackControlsRow.setTotalTime(mediaPlayer.getDuration()); + if (event.getAudioAlbumArt() != null) { + getBackground().setPadding(0, 0, 0, 0); + getBackground().setImageBitmap(event.getAudioAlbumArt()); + mPlaybackControlsRow.setImageBitmap(getActivity(), event.getAudioAlbumArt()); + } else { + Drawable audioDrawable = ContextCompat.getDrawable(getActivity(), R.drawable.tv_ic_audio); + getBackground().setPadding(100, 100, 100, 100); + getBackground().setImageDrawable(audioDrawable); + } + BusProvider.getBus().unregister(this); + } + + @Override + public void onResume() { + super.onResume(); + BusProvider.getBus().register(this); + } + + @Override + public void onPause() { + super.onPause(); + mediaPlayer.pause(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mediaPlayer.release(); + mediaPlayer = null; + } + + public PlaybackControlsRow.PlayPauseAction getmPlayPauseAction() { + return mPlayPauseAction; + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackVideoFragment.java b/src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackVideoFragment.java new file mode 100644 index 000000000..dca14f94f --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/fragment/TvPlaybackVideoFragment.java @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.fragment; + +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.media.session.PlaybackState; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.v17.leanback.app.PlaybackFragment; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.ControlButtonPresenterSelector; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.OnItemViewClickedListener; +import android.support.v17.leanback.widget.OnItemViewSelectedListener; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.support.v17.leanback.widget.PlaybackControlsRowPresenter; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.Row; +import android.support.v17.leanback.widget.RowPresenter; +import android.support.v4.content.ContextCompat; +import android.util.DisplayMetrics; +import android.view.SurfaceHolder; +import android.view.SurfaceView; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.presenter.MainTVPresenter; +import org.amahi.anywhere.tv.presenter.VideoDetailsDescriptionPresenter; +import org.amahi.anywhere.util.Fragments; +import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.util.Mimes; +import org.videolan.libvlc.IVLCVout; +import org.videolan.libvlc.LibVLC; +import org.videolan.libvlc.Media; +import org.videolan.libvlc.MediaPlayer; + +import java.util.ArrayList; + +import javax.inject.Inject; + +import wseemann.media.FFmpegMediaMetadataRetriever; + + +public class TvPlaybackVideoFragment extends PlaybackFragment { + + private static final int DEFAULT_UPDATE_PERIOD = 1000; + private static final int UPDATE_PERIOD = 16; + @Inject + ServerClient serverClient; + private SurfaceHolder mSurfaceHolder; + private MediaPlayer mediaPlayer; + private LibVLC mLibVlc; + private Handler mHandler; + private Runnable mRunnable; + private Object mSavedState; + private PlaybackControlsRow mPlaybackControlsRow; + private int mCurrentPlaybackState; + private int mDuration; + private ArrayList mVideoList; + private ArrayObjectAdapter mRowsAdapter; + private ArrayObjectAdapter mPrimaryActionsAdapter; + private PlaybackControlsRow.SkipPreviousAction mSkipPreviousAction; + private PlaybackControlsRow.PlayPauseAction mPlayPauseAction; + private PlaybackControlsRow.FastForwardAction mFastForwardAction; + private PlaybackControlsRow.RewindAction mRewindAction; + private PlaybackControlsRow.SkipNextAction mSkipNextAction; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setUpInjections(); + + setAllVideoFiles(); + + setDuration(); + + playVideo(); + + setBackgroundType(BG_NONE); + + setFadingEnabled(false); + + mHandler = new Handler(Looper.getMainLooper()); + + setUpRows(); + + addOtherRows(); + + mediaPlayer.setEventListener(new MediaPlayer.EventListener() { + @Override + public void onEvent(MediaPlayer.Event event) { + if (event.type == MediaPlayer.Event.EndReached) { + skipNext(); + } + } + }); + + setOnItemViewClickedListener(new OnItemViewClickedListener() { + @Override + public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { + if (item instanceof ServerFile) { + ServerFile serverFile = (ServerFile) item; + replaceFragment(serverFile); + } + } + }); + + setOnItemViewSelectedListener(new OnItemViewSelectedListener() { + @Override + public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { + mSavedState = item; + } + }); + } + + private ArrayList setAllVideoFiles() { + mVideoList = new ArrayList<>(); + ArrayList allFiles = getArguments().getParcelableArrayList(Intents.Extras.SERVER_FILES); + if (allFiles != null) { + for (ServerFile serverFile : allFiles) { + if (isVideo(serverFile)) { + mVideoList.add(serverFile); + } + } + } + return mVideoList; + } + + private void replaceFragment(ServerFile serverFile) { + getFragmentManager().beginTransaction().replace(R.id.playback_controls_fragment_container, Fragments.Builder.buildVideoFragment(serverFile, getVideoShare(), getVideoFiles())).commit(); + } + + private boolean isVideo(ServerFile serverFile) { + return Mimes.match(serverFile.getMime()) == Mimes.Type.VIDEO; + } + + private boolean isMetadataAvailable() { + return ServerShare.Tag.MOVIES.equals(getVideoShare().getTag()); + } + + private void setDuration() { + FFmpegMediaMetadataRetriever mFFmpegMediaMetadataRetriever = new FFmpegMediaMetadataRetriever(); + mFFmpegMediaMetadataRetriever.setDataSource(getFileUri().toString()); + String mVideoDuration = mFFmpegMediaMetadataRetriever.extractMetadata(FFmpegMediaMetadataRetriever.METADATA_KEY_DURATION); + mDuration = Integer.parseInt(mVideoDuration); + } + + private void playVideo() { + SurfaceView mSurfaceView = (SurfaceView) getActivity().findViewById(R.id.surfaceView); + mSurfaceHolder = mSurfaceView.getHolder(); + setVideoHolder(); + setLibVlc(); + + mediaPlayer = new MediaPlayer(mLibVlc); + Media media = new Media(mLibVlc, getFileUri()); + mediaPlayer.setMedia(media); + media.release(); + + final IVLCVout vlcVout = mediaPlayer.getVLCVout(); + vlcVout.setVideoView(mSurfaceView); + + manageLayout(vlcVout); + + vlcVout.attachViews(); + mediaPlayer.play(); + } + + private void setUpInjections() { + AmahiApplication.from(getActivity()).inject(this); + } + + private void setVideoHolder() { + mSurfaceHolder.setFormat(PixelFormat.RGBX_8888); + mSurfaceHolder.setKeepScreenOn(true); + } + + private void setLibVlc() { + final ArrayList args = new ArrayList<>(); + args.add("-vvv"); + mLibVlc = new LibVLC(getActivity(), args); + } + + private void manageLayout(IVLCVout vlcVout) { + DisplayMetrics displayMetrics = new DisplayMetrics(); + getActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + vlcVout.setWindowSize(displayMetrics.widthPixels, displayMetrics.heightPixels); + } + + private void setUpRows() { + ClassPresenterSelector ps = new ClassPresenterSelector(); + + PlaybackControlsRowPresenter playbackControlsRowPresenter = new PlaybackControlsRowPresenter(new VideoDetailsDescriptionPresenter(getActivity())); + + ps.addClassPresenter(PlaybackControlsRow.class, playbackControlsRowPresenter); + ps.addClassPresenter(ListRow.class, new ListRowPresenter()); + + mRowsAdapter = new ArrayObjectAdapter(ps); + + addPlaybackControlsRow(); + + playbackControlsRowPresenter.setBackgroundColor(ContextCompat.getColor(getActivity(), R.color.primary)); + playbackControlsRowPresenter.setProgressColor(Color.WHITE); + + mPlaybackControlsRow.setTotalTime(mDuration); + playbackControlsRowPresenter.setOnActionClickedListener(new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == mPlayPauseAction.getId()) { + togglePlayPause(mPlayPauseAction.getIndex() == PlaybackControlsRow.PlayPauseAction.PAUSE); + } else if (action.getId() == mRewindAction.getId()) { + setFadingEnabled(false); + rewind(); + } else if (action.getId() == mFastForwardAction.getId()) { + setFadingEnabled(false); + fastForward(); + } else if (action.getId() == mSkipNextAction.getId()) { + skipNext(); + } else if (action.getId() == mSkipPreviousAction.getId()) { + skipPrevious(); + } + if (action instanceof PlaybackControlsRow.MultiAction) { + notifyChanged(action); + } + } + }); + setAdapter(mRowsAdapter); + } + + private void addOtherRows() { + ArrayObjectAdapter adapter = new ArrayObjectAdapter(new MainTVPresenter(getActivity(), serverClient, getVideoShare())); + + for (ServerFile serverFile : mVideoList) adapter.add(serverFile); + + mRowsAdapter.add(new ListRow(getHeader(), adapter)); + } + + private HeaderItem getHeader() { + HeaderItem headerItem; + + if (getVideoFile().getParentFile() == null) + headerItem = new HeaderItem("Video(s) in " + getVideoShare().getName()); + else + headerItem = new HeaderItem("Video(s) in " + getVideoFile().getParentFile().getName()); + + return headerItem; + } + + public void togglePlayPause(boolean isPaused) { + if (isPaused) { + mediaPlayer.pause(); + } else { + mediaPlayer.play(); + } + playbackStateChanged(); + } + + public void rewind() { + if (mPlaybackControlsRow.getCurrentTime() - (10 * 1000) > 0) { + mediaPlayer.setTime(mPlaybackControlsRow.getCurrentTime() - (10 * 1000)); + mPlaybackControlsRow.setCurrentTime((int) mediaPlayer.getTime()); + } + setFadingEnabled(true); + } + + public void fastForward() { + if (mPlaybackControlsRow.getCurrentTime() + (10 * 1000) < mDuration) { + mediaPlayer.setTime(mPlaybackControlsRow.getCurrentTime() + (10 * 1000)); + mPlaybackControlsRow.setCurrentTime((int) mediaPlayer.getTime()); + } + setFadingEnabled(true); + } + + public void skipPrevious() { + int presentIndex = mVideoList.indexOf(getVideoFile()); + if (presentIndex < mVideoList.size() - 1) + replaceFragment(mVideoList.get(presentIndex + 1)); + else + replaceFragment(mVideoList.get(0)); + } + + public void skipNext() { + int presentIndex = mVideoList.indexOf(getVideoFile()); + if (presentIndex > 0) + replaceFragment(mVideoList.get(presentIndex - 1)); + else + replaceFragment(mVideoList.get(mVideoList.size() - 1)); + } + + private void addPlaybackControlsRow() { + mPlaybackControlsRow = new PlaybackControlsRow(getVideoFile()); + mRowsAdapter.add(mPlaybackControlsRow); + + ControlButtonPresenterSelector presenterSelector = new ControlButtonPresenterSelector(); + mPrimaryActionsAdapter = new ArrayObjectAdapter(presenterSelector); + mPlaybackControlsRow.setPrimaryActionsAdapter(mPrimaryActionsAdapter); + + mRewindAction = new PlaybackControlsRow.RewindAction(getActivity()); + mPlayPauseAction = new PlaybackControlsRow.PlayPauseAction(getActivity()); + mFastForwardAction = new PlaybackControlsRow.FastForwardAction(getActivity()); + mSkipNextAction = new PlaybackControlsRow.SkipNextAction(getActivity()); + mSkipPreviousAction = new PlaybackControlsRow.SkipPreviousAction(getActivity()); + if (!isMetadataAvailable()) + mPrimaryActionsAdapter.add(mSkipPreviousAction); + mPrimaryActionsAdapter.add(mRewindAction); + mPrimaryActionsAdapter.add(mPlayPauseAction); + mPrimaryActionsAdapter.add(mFastForwardAction); + if (!isMetadataAvailable()) + mPrimaryActionsAdapter.add(mSkipNextAction); + playbackStateChanged(); + } + + public void playbackStateChanged() { + if (mCurrentPlaybackState != PlaybackState.STATE_PLAYING) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mCurrentPlaybackState = PlaybackState.STATE_PLAYING; + } + startProgressAutomation(); + setFadingEnabled(true); + mPlayPauseAction.setIndex(PlaybackControlsRow.PlayPauseAction.PAUSE); + mPlayPauseAction.setIcon(mPlayPauseAction.getDrawable(PlaybackControlsRow.PlayPauseAction.PAUSE)); + notifyChanged(mPlayPauseAction); + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mCurrentPlaybackState = PlaybackState.STATE_PAUSED; + } + stopProgressAutomation(); + setFadingEnabled(false); + mPlayPauseAction.setIndex(PlaybackControlsRow.PlayPauseAction.PLAY); + mPlayPauseAction.setIcon(mPlayPauseAction.getDrawable(PlaybackControlsRow.PlayPauseAction.PLAY)); + notifyChanged(mPlayPauseAction); + } + } + + private void startProgressAutomation() { + if (mRunnable == null) { + mRunnable = new Runnable() { + @Override + public void run() { + int updatePeriod = getUpdatePeriod(); + int currentTime = mPlaybackControlsRow.getCurrentTime() + updatePeriod; + int totalTime = mPlaybackControlsRow.getTotalTime(); + mPlaybackControlsRow.setCurrentTime(currentTime); + if (totalTime > 0 && totalTime <= currentTime) { + stopProgressAutomation(); + } else { + mHandler.postDelayed(this, updatePeriod); + } + } + }; + mHandler.postDelayed(mRunnable, getUpdatePeriod()); + } + } + + private void stopProgressAutomation() { + if (mHandler != null && mRunnable != null) { + mHandler.removeCallbacks(mRunnable); + mRunnable = null; + } + } + + private int getUpdatePeriod() { + if (getView() == null || mPlaybackControlsRow.getTotalTime() <= 0) { + return DEFAULT_UPDATE_PERIOD; + } + return Math.max(UPDATE_PERIOD, mPlaybackControlsRow.getTotalTime() / getView().getWidth()); + } + + private void notifyChanged(Action action) { + ArrayObjectAdapter adapter = mPrimaryActionsAdapter; + if (adapter.indexOf(action) >= 0) { + adapter.notifyArrayItemRangeChanged(adapter.indexOf(action), 1); + } + } + + private ServerShare getVideoShare() { + return getArguments().getParcelable(Intents.Extras.SERVER_SHARE); + } + + private Uri getFileUri() { + return serverClient.getFileUri(getVideoShare(), getVideoFile()); + } + + private ServerFile getVideoFile() { + return getArguments().getParcelable(Intents.Extras.SERVER_FILE); + } + + private ArrayList getVideoFiles() { + return getArguments().getParcelableArrayList(Intents.Extras.SERVER_FILES); + } + + @Override + public void onPause() { + super.onPause(); + mediaPlayer.pause(); + } + + @Override + public void onResume() { + super.onResume(); + mediaPlayer.play(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mLibVlc.release(); + mediaPlayer.stop(); + mediaPlayer.release(); + mediaPlayer = null; + } + + public PlaybackControlsRow.PlayPauseAction getmPlayPauseAction() { + return mPlayPauseAction; + } + + public Object getmSavedState() { + return mSavedState; + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/presenter/AudioDetailsDescriptionPresenter.java b/src/main/java/org/amahi/anywhere/tv/presenter/AudioDetailsDescriptionPresenter.java new file mode 100644 index 000000000..3691fad1e --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/presenter/AudioDetailsDescriptionPresenter.java @@ -0,0 +1,46 @@ +package org.amahi.anywhere.tv.presenter; + +import android.content.Context; +import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter; +import android.text.format.Formatter; + +import org.amahi.anywhere.bus.AudioMetadataRetrievedEvent; +import org.amahi.anywhere.server.model.ServerFile; + +import java.text.SimpleDateFormat; +import java.util.Date; + +public class AudioDetailsDescriptionPresenter extends AbstractDetailsDescriptionPresenter { + private Context mContext; + + public AudioDetailsDescriptionPresenter(Context context) { + this.mContext = context; + } + + @Override + protected void onBindDescription(ViewHolder viewHolder, Object item) { + AudioMetadataRetrievedEvent event = (AudioMetadataRetrievedEvent) item; + + if (event.getAudioTitle() != null) + viewHolder.getTitle().setText(event.getAudioTitle()); + else + viewHolder.getTitle().setText(event.getServerFile().getName()); + + if (event.getAudioAlbum() != null && event.getAudioArtist() != null) { + viewHolder.getSubtitle().setText(event.getAudioAlbum() + " - " + event.getAudioArtist()); + } else + viewHolder.getSubtitle().setText(getDate(event.getServerFile())); + + viewHolder.getBody().setText(getSize(event.getServerFile())); + } + + private String getDate(ServerFile serverFile) { + Date d = serverFile.getModificationTime(); + SimpleDateFormat dt = new SimpleDateFormat("EEE LLL dd yyyy"); + return dt.format(d); + } + + private String getSize(ServerFile serverFile) { + return Formatter.formatFileSize(mContext, serverFile.getSize()); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/presenter/IconHeaderPresenter.java b/src/main/java/org/amahi/anywhere/tv/presenter/IconHeaderPresenter.java new file mode 100644 index 000000000..d52c1433a --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/presenter/IconHeaderPresenter.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.presenter; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.RowHeaderPresenter; +import android.support.v4.content.ContextCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.amahi.anywhere.R; + +public class IconHeaderPresenter extends RowHeaderPresenter { + + private float mUnselectedAlpha; + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + mUnselectedAlpha = parent.getResources() + .getFraction(R.fraction.lb_browse_header_unselect_alpha, 1, 1); + + LayoutInflater inflater = (LayoutInflater) parent.getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + View view = inflater.inflate(R.layout.tv_header_item, null); + view.setAlpha(mUnselectedAlpha); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) { + HeaderItem headerItem = ((ListRow) item).getHeaderItem(); + View rootView = viewHolder.view; + rootView.setFocusable(true); + ImageView imageView = (ImageView) rootView.findViewById(R.id.header_icon); + + if (headerItem.getName().matches("Settings")) { + imageView.setVisibility(View.VISIBLE); + Drawable icon = ContextCompat.getDrawable(rootView.getContext(), R.drawable.ic_menu_settings); + imageView.setImageDrawable(icon); + } else { + imageView.setVisibility(View.GONE); + TextView label = (TextView) rootView.findViewById(R.id.header_label); + label.setTextColor(Color.WHITE); + label.setText(headerItem.getName()); + } + } + + @Override + public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { + // Nothing to be done here. + } + + @Override + protected void onSelectLevelChanged(RowHeaderPresenter.ViewHolder holder) { + holder.view.setAlpha(mUnselectedAlpha + holder.getSelectLevel() * + (1.0f - mUnselectedAlpha)); + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/presenter/MainTVPresenter.java b/src/main/java/org/amahi/anywhere/tv/presenter/MainTVPresenter.java new file mode 100644 index 000000000..6405c2228 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/presenter/MainTVPresenter.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.presenter; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.support.v17.leanback.widget.ImageCardView; +import android.support.v17.leanback.widget.Presenter; +import android.support.v4.content.ContextCompat; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.adapter.ServerFilesMetadataAdapter; +import org.amahi.anywhere.bus.AudioMetadataRetrievedEvent; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.FileMetadataRetrievedEvent; +import org.amahi.anywhere.bus.FileOpeningEvent; +import org.amahi.anywhere.server.client.ServerClient; +import org.amahi.anywhere.server.model.ServerFile; +import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.task.AudioMetadataRetrievingTask; +import org.amahi.anywhere.task.FileMetadataRetrievingTask; +import org.amahi.anywhere.util.Intents; +import org.amahi.anywhere.util.Mimes; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class MainTVPresenter extends Presenter { + + private Context mContext; + private int mSelectedBackgroundColor = -1; + private int mDefaultBackgroundColor = -1; + private ServerClient mServerClient; + private List mServerFileList; + private ServerShare parentShare; + + public MainTVPresenter(Context context, ServerClient serverClient, List serverFiles) { + mContext = context; + mServerClient = serverClient; + mServerFileList = serverFiles; + BusProvider.getBus().register(this); + } + + public MainTVPresenter(Context context, ServerClient serverClient) { + mContext = context; + mServerClient = serverClient; + BusProvider.getBus().register(this); + } + + public MainTVPresenter(Context context, ServerClient serverClient, ServerShare parentShare) { + mContext = context; + mServerClient = serverClient; + this.parentShare = parentShare; + } + + @Override + public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent) { + mDefaultBackgroundColor = + ContextCompat.getColor(parent.getContext(), R.color.background_secondary); + mSelectedBackgroundColor = + ContextCompat.getColor(parent.getContext(), R.color.primary); + ImageCardView cardView = new ImageCardView(parent.getContext()) { + @Override + public void setSelected(boolean selected) { + updateCardBackgroundColor(this, selected); + super.setSelected(selected); + } + }; + cardView.setFocusable(true); + cardView.setFocusableInTouchMode(true); + return new ViewHolder(cardView); + } + + private void updateCardBackgroundColor(ImageCardView view, boolean selected) { + int color = selected ? mSelectedBackgroundColor : mDefaultBackgroundColor; + + view.setInfoAreaBackgroundColor(color); + } + + @Override + public void onBindViewHolder(Presenter.ViewHolder viewHolderArgs, Object item) { + final ServerFile serverFile = (ServerFile) item; + ViewHolder viewHolder = (ViewHolder) viewHolderArgs; + viewHolder.mCardView.setTitleText(serverFile.getName()); + viewHolder.mCardView.setInfoAreaBackgroundColor(mDefaultBackgroundColor); + viewHolder.mCardView.setBackgroundColor(mDefaultBackgroundColor); + if (isMetadataAvailable(serverFile)) { + setUpMetaDimensions(viewHolder); + if (isVideo(serverFile)) { + View fileView = viewHolder.view; + fileView.setTag(ServerFilesMetadataAdapter.Tags.SHARE, serverFile.getParentShare()); + fileView.setTag(ServerFilesMetadataAdapter.Tags.FILE, serverFile); + new FileMetadataRetrievingTask(mServerClient, fileView, viewHolder).execute(); + } else if (isDirectory(serverFile)) + viewHolder.mCardView.setVisibility(View.VISIBLE); + else + viewHolder.mCardView.setVisibility(View.GONE); + } else { + setUpDimensions(viewHolder); + } + populateData(serverFile, viewHolder); + viewHolder.mCardView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (isDirectory(serverFile)) { + Intent intent = Intents.Builder.with(mContext).buildServerTvFilesActivity(serverFile.getParentShare(), serverFile); + mContext.startActivity(intent); + } else { + startFileOpening(serverFile); + } + } + }); + } + + + private boolean isMetadataAvailable(ServerFile serverFile) { + if (parentShare == null) + return ServerShare.Tag.MOVIES.equals(serverFile.getParentShare().getTag()); + else + return ServerShare.Tag.MOVIES.equals(parentShare.getTag()); + } + + private void setUpMetaDimensions(ViewHolder viewHolder) { + viewHolder.mCardView.setMainImageDimensions(400, 500); + } + + private void setDate(ServerFile serverFile, ViewHolder viewHolder) { + Date d = serverFile.getModificationTime(); + SimpleDateFormat dt = new SimpleDateFormat("EEE LLL dd yyyy", Locale.US); + viewHolder.mCardView.setContentText(dt.format(d)); + } + + private void populateData(ServerFile serverFile, ViewHolder viewHolder) { + if (isDirectory(serverFile)) + setDate(serverFile, viewHolder); + if (isMetadataAvailable(serverFile) && isVideo(serverFile)) + viewHolder.mCardView.setContentText(""); + if (isImage(serverFile)) { + setUpImageIcon(serverFile, viewHolder.mCardView.getMainImageView(), getImageUri(serverFile)); + } else if (isAudio(serverFile)) { + AudioMetadataRetrievingTask.execute(getImageUri(serverFile), serverFile, viewHolder); + } else { + setUpDrawable(serverFile, viewHolder); + } + } + + private void setUpDimensions(ViewHolder viewHolder) { + viewHolder.mCardView.setMainImageDimensions(400, 300); + } + + private void setUpDrawable(ServerFile serverFile, ViewHolder viewHolder) { + viewHolder.mCardView.setMainImageScaleType(ImageView.ScaleType.CENTER_INSIDE); + viewHolder.mCardView.setMainImage(ContextCompat.getDrawable(mContext, Mimes.getTVFileIcon(serverFile))); + if (!isMetadataAvailable(serverFile)) + viewHolder.mCardView.getMainImageView().setPadding(50, 50, 50, 50); + } + + @Subscribe + public void onFileMetadataRetrieved(FileMetadataRetrievedEvent event) { + ServerFile serverFile = event.getFile(); + ViewHolder viewHolder = event.getViewHolder(); + serverFile.setMetaDataFetched(true); + if (event.getFileMetadata() == null) { + populateData(serverFile, viewHolder); + } else { + viewHolder.mCardView.setMainImageScaleType(ImageView.ScaleType.CENTER_CROP); + setUpImageIcon(serverFile, viewHolder.mCardView.getMainImageView(), Uri.parse(event.getFileMetadata().getArtworkUrl())); + } + } + + @Subscribe + public void onAudioMetadataRetrieved(AudioMetadataRetrievedEvent event) { + if (event.getAudioAlbumArt() != null) { + ViewHolder viewHolder = event.getViewHolder(); + if (viewHolder != null) { + viewHolder.mCardView.getMainImageView().setImageBitmap(event.getAudioAlbumArt()); + } + } else + setUpMusicLogo(event.getViewHolder()); + } + + private void setUpMusicLogo(ViewHolder viewHolder) { + if (viewHolder != null) + viewHolder.mCardView.setMainImage(ContextCompat.getDrawable(mContext, R.drawable.tv_ic_audio)); + } + + private boolean isImage(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.IMAGE; + } + + private boolean isAudio(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.AUDIO; + } + + private boolean isDirectory(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.DIRECTORY; + } + + private boolean isVideo(ServerFile file) { + return Mimes.match(file.getMime()) == Mimes.Type.VIDEO; + } + + private void setUpImageIcon(ServerFile file, ImageView fileIconView, Uri url) { + Glide.with(fileIconView.getContext()) + .load(url.toString()) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .centerCrop() + .placeholder(Mimes.getTVFileIcon(file)) + .into(fileIconView); + } + + private Uri getImageUri(ServerFile file) { + if (parentShare == null) + return mServerClient.getFileUri(file.getParentShare(), file); + else + return mServerClient.getFileUri(parentShare, file); + } + + private void startFileOpening(ServerFile file) { + BusProvider.getBus().post(new FileOpeningEvent(file.getParentShare(), mServerFileList, file)); + } + + @Override + public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { + } + + public class ViewHolder extends Presenter.ViewHolder { + private ImageCardView mCardView; + + ViewHolder(View view) { + super(view); + mCardView = (ImageCardView) view; + mCardView.getMainImageView().setImageDrawable(ContextCompat.getDrawable(mContext, R.drawable.tv_ic_audio)); + } + } +} diff --git a/src/main/java/org/amahi/anywhere/tv/presenter/SettingsItemPresenter.java b/src/main/java/org/amahi/anywhere/tv/presenter/SettingsItemPresenter.java new file mode 100644 index 000000000..ac6a2f6ec --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/presenter/SettingsItemPresenter.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.presenter; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.support.v17.leanback.widget.Presenter; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.model.Server; +import org.amahi.anywhere.tv.activity.SettingsActivity; + +import java.util.ArrayList; + +public class SettingsItemPresenter extends Presenter { + + private static final int SETTINGS_ITEM_WIDTH = 400; + private static final int SETTINGS_ITEM_HEIGHT = 200; + private Context mContext; + private ArrayList serverArrayList; + + public SettingsItemPresenter() { + } + + public SettingsItemPresenter(ArrayList serverArrayList) { + this.serverArrayList = serverArrayList; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + mContext = parent.getContext(); + + TextView view = new TextView(mContext); + + view.setLayoutParams(new ViewGroup.LayoutParams(SETTINGS_ITEM_WIDTH, SETTINGS_ITEM_HEIGHT)); + + view.setFocusable(true); + + view.setFocusableInTouchMode(true); + + view.setBackgroundColor(Color.DKGRAY); + + view.setTextColor(Color.WHITE); + + view.setGravity(Gravity.CENTER); + + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(final ViewHolder viewHolder, Object item) { + TextView settingsTv = (TextView) viewHolder.view; + + final String settingsText = (String) item; + + settingsTv.setText(settingsText); + + settingsTv.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (settingsText.matches(mContext.getString(R.string.pref_title_server_select))) { + Intent intent = new Intent(mContext, SettingsActivity.class); + intent.putExtra(Intent.EXTRA_TEXT, mContext.getString(R.string.pref_title_server_select)); + intent.putParcelableArrayListExtra(mContext.getString(R.string.intent_servers), serverArrayList); + mContext.startActivity(intent); + } + + if (settingsText.matches(mContext.getString(R.string.pref_title_sign_out))) { + mContext.startActivity(new Intent(mContext, SettingsActivity.class).putExtra(Intent.EXTRA_TEXT, mContext.getString(R.string.pref_title_sign_out))); + } else if (settingsText.matches(mContext.getString(R.string.pref_title_connection))) { + mContext.startActivity(new Intent(mContext, SettingsActivity.class).putExtra(Intent.EXTRA_TEXT, mContext.getString(R.string.pref_title_connection))); + } else if (settingsText.matches(mContext.getString(R.string.pref_title_select_theme))) { + mContext.startActivity(new Intent(mContext, SettingsActivity.class).putExtra(Intent.EXTRA_TEXT, mContext.getString(R.string.pref_title_select_theme))); + } + } + }); + } + + @Override + public void onUnbindViewHolder(ViewHolder viewHolder) { + } +} \ No newline at end of file diff --git a/src/main/java/org/amahi/anywhere/tv/presenter/VideoDetailsDescriptionPresenter.java b/src/main/java/org/amahi/anywhere/tv/presenter/VideoDetailsDescriptionPresenter.java new file mode 100644 index 000000000..62b576866 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/tv/presenter/VideoDetailsDescriptionPresenter.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.tv.presenter; + +import android.content.Context; +import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter; +import android.text.format.Formatter; + +import org.amahi.anywhere.server.model.ServerFile; + +import java.text.SimpleDateFormat; +import java.util.Date; + + +public class VideoDetailsDescriptionPresenter extends AbstractDetailsDescriptionPresenter { + + private Context mContext; + + public VideoDetailsDescriptionPresenter(Context context) { + mContext = context; + } + + @Override + protected void onBindDescription(ViewHolder viewHolder, Object item) { + ServerFile serverFile = (ServerFile) item; + viewHolder.getTitle().setText(serverFile.getName()); + viewHolder.getSubtitle().setText(getDate(serverFile)); + viewHolder.getBody().setText(getSize(serverFile)); + } + + private String getDate(ServerFile serverFile) { + Date d = serverFile.getModificationTime(); + SimpleDateFormat dt = new SimpleDateFormat("EEE LLL dd yyyy"); + return dt.format(d); + } + + private String getSize(ServerFile serverFile) { + return Formatter.formatFileSize(mContext, serverFile.getSize()); + } +} diff --git a/src/main/java/org/amahi/anywhere/util/Android.java b/src/main/java/org/amahi/anywhere/util/Android.java index 9ef6abec3..42b6ce59e 100644 --- a/src/main/java/org/amahi/anywhere/util/Android.java +++ b/src/main/java/org/amahi/anywhere/util/Android.java @@ -27,6 +27,9 @@ import org.amahi.anywhere.BuildConfig; import org.amahi.anywhere.R; +import java.io.IOException; +import java.io.InputStream; + /** * Android properties accessor. */ @@ -38,6 +41,10 @@ public static boolean isTablet(Context context) { return context.getResources().getBoolean(R.bool.tablet); } + public static boolean isPermissionRequired() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; + } + public static String getVersion() { return Build.VERSION.RELEASE; } @@ -80,4 +87,18 @@ private static DisplayMetrics getDeviceScreenMetrics(Context context) { return screenMetrics; } + + public static String loadServersFromAsset(Context context) { + String json = "[]"; + try { + InputStream is = context.getAssets().open("customServers.json"); + int size = is.available(); + byte[] buffer = new byte[size]; + is.read(buffer); + is.close(); + json = new String(buffer, "UTF-8"); + } catch (IOException ignored) { + } + return json; + } } diff --git a/src/main/java/org/amahi/anywhere/util/AudioMetadataFormatter.java b/src/main/java/org/amahi/anywhere/util/AudioMetadataFormatter.java index 48d0997a2..c443c3c34 100644 --- a/src/main/java/org/amahi/anywhere/util/AudioMetadataFormatter.java +++ b/src/main/java/org/amahi/anywhere/util/AudioMetadataFormatter.java @@ -32,6 +32,7 @@ public final class AudioMetadataFormatter { private final String audioTitle; private final String audioArtist; private final String audioAlbum; + private long duration; public AudioMetadataFormatter(String audioTitle, String audioArtist, String audioAlbum) { this.audioTitle = audioTitle; @@ -39,6 +40,14 @@ public AudioMetadataFormatter(String audioTitle, String audioArtist, String audi this.audioAlbum = audioAlbum; } + public long getDuration() { + return duration; + } + + public void setDuration(long duration) { + this.duration = duration; + } + public String getAudioTitle(ServerFile audioFile) { if (TextUtils.isEmpty(audioTitle)) { return audioFile.getName(); @@ -70,4 +79,12 @@ public String getAudioSubtitle(ServerShare audioShare) { return String.format("%s - %s", audioArtist, audioAlbum); } + + public String getAudioArtist() { + return audioArtist; + } + + public String getAudioAlbum() { + return audioAlbum; + } } diff --git a/src/main/java/org/amahi/anywhere/util/CastOptionsProvider.java b/src/main/java/org/amahi/anywhere/util/CastOptionsProvider.java new file mode 100644 index 000000000..ccf01c167 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/CastOptionsProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.util; + +import android.content.Context; + +import com.google.android.gms.cast.framework.CastOptions; +import com.google.android.gms.cast.framework.OptionsProvider; +import com.google.android.gms.cast.framework.SessionProvider; +import com.google.android.gms.cast.framework.media.CastMediaOptions; +import com.google.android.gms.cast.framework.media.NotificationOptions; + +import org.amahi.anywhere.BuildConfig; +import org.amahi.anywhere.activity.ExpandedControlsActivity; + +import java.util.List; + +/** + * Cast options provider helper class + */ +public class CastOptionsProvider implements OptionsProvider { + + @Override + public CastOptions getCastOptions(Context appContext) { + NotificationOptions notificationOptions = new NotificationOptions.Builder() + .setTargetActivityClassName(ExpandedControlsActivity.class.getName()) + .build(); + CastMediaOptions mediaOptions = new CastMediaOptions.Builder() + .setNotificationOptions(notificationOptions) + .setExpandedControllerActivityClassName(ExpandedControlsActivity.class.getName()) + .build(); + + return new CastOptions.Builder() + .setReceiverApplicationId(BuildConfig.CHROMECAST_APP_ID) + .setCastMediaOptions(mediaOptions) + .build(); + } + + @Override + public List getAdditionalSessionProviders(Context context) { + return null; + } + +} diff --git a/src/main/java/org/amahi/anywhere/util/CheckTV.java b/src/main/java/org/amahi/anywhere/util/CheckTV.java new file mode 100644 index 000000000..a3caae0cf --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/CheckTV.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.util; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.res.Configuration; + +import static android.content.Context.UI_MODE_SERVICE; + +public class CheckTV { + + public static boolean isATV(Context context) { + UiModeManager uiModeManager = (UiModeManager) context.getSystemService(UI_MODE_SERVICE); + return (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION); + } +} diff --git a/src/main/java/org/amahi/anywhere/util/Downloader.java b/src/main/java/org/amahi/anywhere/util/Downloader.java index 56aba0da8..5aeb4b158 100644 --- a/src/main/java/org/amahi/anywhere/util/Downloader.java +++ b/src/main/java/org/amahi/anywhere/util/Downloader.java @@ -27,11 +27,14 @@ import android.database.Cursor; import android.net.Uri; import android.os.Environment; +import android.support.v4.content.FileProvider; import org.amahi.anywhere.bus.BusProvider; import org.amahi.anywhere.bus.FileDownloadFailedEvent; import org.amahi.anywhere.bus.FileDownloadedEvent; +import java.io.File; + import javax.inject.Inject; import javax.inject.Singleton; @@ -66,10 +69,16 @@ private void setUpDownloadReceiver() { } private void startDownloading(Uri downloadUri, String downloadName) { + + //code to delete the file if it already exists + File file = new File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + "/" + downloadName); + if (file.exists()) + file.delete(); + DownloadManager.Request downloadRequest = new DownloadManager.Request(downloadUri) - .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadName) - .setVisibleInDownloadsUi(false) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); + .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadName) + .setVisibleInDownloadsUi(false) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); this.downloadId = getDownloadManager(context).enqueue(downloadRequest); } @@ -93,20 +102,26 @@ private boolean isDownloadCurrent(Intent intent) { private void finishDownloading() { DownloadManager.Query downloadQuery = new DownloadManager.Query() - .setFilterById(downloadId); + .setFilterById(downloadId); Cursor downloadInformation = getDownloadManager(context).query(downloadQuery); downloadInformation.moveToFirst(); int downloadStatus = downloadInformation.getInt( - downloadInformation.getColumnIndex(DownloadManager.COLUMN_STATUS)); + downloadInformation.getColumnIndex(DownloadManager.COLUMN_STATUS)); if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { String downloadUri = downloadInformation.getString( - downloadInformation.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)); + downloadInformation.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)); + + if (downloadUri.substring(0, 7).matches("file://")) { + downloadUri = downloadUri.substring(7); + } + File file = new File(downloadUri); + Uri contentUri = FileProvider.getUriForFile(context, "org.amahi.anywhere.fileprovider", file); - BusProvider.getBus().post(new FileDownloadedEvent(Uri.parse(downloadUri))); + BusProvider.getBus().post(new FileDownloadedEvent(contentUri)); } else { BusProvider.getBus().post(new FileDownloadFailedEvent()); } diff --git a/src/main/java/org/amahi/anywhere/util/Fragments.java b/src/main/java/org/amahi/anywhere/util/Fragments.java index 1f469035e..4e20c6a0d 100644 --- a/src/main/java/org/amahi/anywhere/util/Fragments.java +++ b/src/main/java/org/amahi/anywhere/util/Fragments.java @@ -29,9 +29,13 @@ import org.amahi.anywhere.fragment.ServerFileImageFragment; import org.amahi.anywhere.fragment.ServerFilesFragment; import org.amahi.anywhere.fragment.ServerSharesFragment; -import org.amahi.anywhere.fragment.SettingsFragment; import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.tv.fragment.ServerFileTvFragment; +import org.amahi.anywhere.tv.fragment.TvPlaybackAudioFragment; +import org.amahi.anywhere.tv.fragment.TvPlaybackVideoFragment; + +import java.util.ArrayList; /** * Fragments accessor. Provides a factory for building fragments and an operator for placing them. @@ -43,6 +47,7 @@ private Fragments() { public static final class Arguments { public static final String SERVER_FILE = "server_file"; public static final String SERVER_SHARE = "server_share"; + private Arguments() { } } @@ -87,6 +92,39 @@ public static Fragment buildServerFileImageFragment(ServerShare share, ServerFil return fileFragment; } + public static android.app.Fragment buildFirstTvFragment(ServerFile serverFile, ServerShare serverShare) { + android.app.Fragment fragment = new ServerFileTvFragment(); + Bundle bundle = new Bundle(); + bundle.putParcelable(Intents.Extras.SERVER_FILE, serverFile); + bundle.putParcelable(Intents.Extras.SERVER_SHARE, serverShare); + fragment.setArguments(bundle); + return fragment; + } + + public static android.app.Fragment buildVideoFragment(ServerFile serverFile, ServerShare serverShare, ArrayList serverFiles) { + android.app.Fragment fragment = new TvPlaybackVideoFragment(); + + Bundle bundle = new Bundle(); + bundle.putParcelable(Intents.Extras.SERVER_SHARE, serverShare); + bundle.putParcelable(Intents.Extras.SERVER_FILE, serverFile); + bundle.putParcelableArrayList(Intents.Extras.SERVER_FILES, serverFiles); + + fragment.setArguments(bundle); + return fragment; + } + + public static android.app.Fragment buildAudioFragment(ServerFile serverFile, ServerShare serverShare, ArrayList serverFiles) { + android.app.Fragment fragment = new TvPlaybackAudioFragment(); + + Bundle bundle = new Bundle(); + bundle.putParcelable(Intents.Extras.SERVER_SHARE, serverShare); + bundle.putParcelable(Intents.Extras.SERVER_FILE, serverFile); + bundle.putParcelableArrayList(Intents.Extras.SERVER_FILES, serverFiles); + + fragment.setArguments(bundle); + return fragment; + } + } public static final class Operator { @@ -106,9 +144,9 @@ public void set(Fragment fragment, int fragmentContainerId) { } fragmentManager - .beginTransaction() - .add(fragmentContainerId, fragment) - .commit(); + .beginTransaction() + .add(fragmentContainerId, fragment) + .commit(); } private boolean isSet(int fragmentContainerId) { @@ -117,17 +155,17 @@ private boolean isSet(int fragmentContainerId) { public void replace(Fragment fragment, int fragmentContainerId) { fragmentManager - .beginTransaction() - .replace(fragmentContainerId, fragment) - .commit(); + .beginTransaction() + .replace(fragmentContainerId, fragment) + .commit(); } public void replaceBackstacked(Fragment fragment, int fragmentContainerId) { fragmentManager - .beginTransaction() - .replace(fragmentContainerId, fragment) - .addToBackStack(null) - .commit(); + .beginTransaction() + .replace(fragmentContainerId, fragment) + .addToBackStack(null) + .commit(); } } } diff --git a/src/main/java/org/amahi/anywhere/util/FullScreenHelper.java b/src/main/java/org/amahi/anywhere/util/FullScreenHelper.java index ab8a4f4d9..5d140f89e 100644 --- a/src/main/java/org/amahi/anywhere/util/FullScreenHelper.java +++ b/src/main/java/org/amahi/anywhere/util/FullScreenHelper.java @@ -47,11 +47,11 @@ public class FullScreenHelper { @Override public void run() { mContentView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); } }; private ActionBar actionBar; @@ -161,7 +161,7 @@ public void hide() { public void show() { // Show the system bar mContentView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); mVisible = true; // Schedule a runnable to display UI elements after a delay diff --git a/src/main/java/org/amahi/anywhere/util/Identifier.java b/src/main/java/org/amahi/anywhere/util/Identifier.java index 604259a80..9ca69c93d 100644 --- a/src/main/java/org/amahi/anywhere/util/Identifier.java +++ b/src/main/java/org/amahi/anywhere/util/Identifier.java @@ -17,12 +17,12 @@ private Identifier() { public static String getUserAgent(Context context) { return String.format(Locale.US, Format.USER_AGENT, - Android.getApplicationVersion(), - Android.getVersion(), - Android.getDeviceName(), - Android.getDeviceScreenSize(context), - Android.getDeviceScreenHeight(context), - Android.getDeviceScreenWidth(context)); + Android.getApplicationVersion(), + Android.getVersion(), + Android.getDeviceName(), + Android.getDeviceScreenSize(context), + Android.getDeviceScreenHeight(context), + Android.getDeviceScreenWidth(context)); } public static String getUserAgent(Context context, Map fields) { @@ -44,6 +44,7 @@ public static String getUserAgent(Context context, Map fields) { private static final class Format { public static final String USER_AGENT = "AmahiAnywhere/%s (Android %s; %s) Size/%.1f Resolution/%dx%d"; public static final String USER_AGENT_FIELD = "%s/%s"; + private Format() { } } diff --git a/src/main/java/org/amahi/anywhere/util/Intents.java b/src/main/java/org/amahi/anywhere/util/Intents.java index abcc6118b..38d111863 100644 --- a/src/main/java/org/amahi/anywhere/util/Intents.java +++ b/src/main/java/org/amahi/anywhere/util/Intents.java @@ -25,8 +25,12 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; +import android.os.Build; import android.os.Parcelable; +import android.provider.MediaStore; +import org.amahi.anywhere.R; +import org.amahi.anywhere.activity.NativeVideoActivity; import org.amahi.anywhere.activity.ServerAppActivity; import org.amahi.anywhere.activity.ServerFileAudioActivity; import org.amahi.anywhere.activity.ServerFileImageActivity; @@ -38,6 +42,11 @@ import org.amahi.anywhere.server.model.ServerApp; import org.amahi.anywhere.server.model.ServerFile; import org.amahi.anywhere.server.model.ServerShare; +import org.amahi.anywhere.service.UploadService; +import org.amahi.anywhere.tv.activity.ServerFileTvActivity; +import org.amahi.anywhere.tv.activity.TVWebViewActivity; +import org.amahi.anywhere.tv.activity.TvPlaybackAudioActivity; +import org.amahi.anywhere.tv.activity.TvPlaybackVideoActivity; import java.util.ArrayList; import java.util.List; @@ -54,14 +63,17 @@ public static final class Extras { public static final String SERVER_FILE = "server_file"; public static final String SERVER_FILES = "server_files"; public static final String SERVER_SHARE = "server_share"; + public static final String IMAGE_URIS = "image_uris"; + private Extras() { } } - public static final class Uris { - public static final String EMAIL = "mailto:%s?subject=%s"; - public static final String GOOGLE_PLAY = "market://details?id=%s"; - public static final String GOOGLE_PLAY_SEARCH = "market://search?q=%s"; + private static final class Uris { + static final String EMAIL = "mailto:%s?subject=%s"; + static final String GOOGLE_PLAY = "market://details?id=%s"; + static final String GOOGLE_PLAY_SEARCH = "market://search?q=%s"; + private Uris() { } } @@ -91,6 +103,14 @@ public Intent buildServerFilesActivity(ServerShare share) { return intent; } + public Intent buildServerTvFilesActivity(ServerShare share, ServerFile file) { + Intent intent = new Intent(context, ServerFileTvActivity.class); + intent.putExtra(Extras.SERVER_FILE, file); + intent.putExtra(Extras.SERVER_SHARE, share); + + return intent; + } + public boolean isServerFileSupported(ServerFile file) { return getServerFileActivity(file) != null; } @@ -99,6 +119,8 @@ private Class getServerFileActivity(ServerFile file) { String fileFormat = file.getMime(); if (ServerFileAudioActivity.supports(fileFormat)) { + if (CheckTV.isATV(context)) + return TvPlaybackAudioActivity.class; return ServerFileAudioActivity.class; } @@ -107,11 +129,20 @@ private Class getServerFileActivity(ServerFile file) { } if (ServerFileVideoActivity.supports(fileFormat)) { + if (CheckTV.isATV(context)) { + return TvPlaybackVideoActivity.class; + } + if (NativeVideoActivity.supports(fileFormat)) { + return NativeVideoActivity.class; + } return ServerFileVideoActivity.class; } if (ServerFileWebActivity.supports(fileFormat)) { - return ServerFileWebActivity.class; + if (!CheckTV.isATV(context)) + return ServerFileWebActivity.class; + else + return TVWebViewActivity.class; } return null; @@ -130,8 +161,8 @@ public boolean isServerFileOpeningSupported(ServerFile file) { PackageManager packageManager = context.getPackageManager(); List applications = packageManager.queryIntentActivities( - buildServerFileOpeningIntent(file), - PackageManager.MATCH_DEFAULT_ONLY); + buildServerFileOpeningIntent(file), + PackageManager.MATCH_DEFAULT_ONLY); return !applications.isEmpty(); } @@ -189,5 +220,32 @@ public Intent buildGooglePlaySearchIntent(String search) { return intent; } + + public Intent buildMediaPickerIntent() { + Intent intent = new Intent(Intent.ACTION_PICK, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + intent.setType("image/* video/*"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"}); + } + intent = Intent.createChooser(intent, context.getString(R.string.message_media_upload)); + return intent; + } + + public Intent buildCameraIntent() { + return new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + } + + public Intent buildUploadServiceIntent(Uri uri) { + ArrayList uris = new ArrayList<>(); + uris.add(uri); + return buildUploadServiceIntent(uris); + } + + public Intent buildUploadServiceIntent(ArrayList uris) { + Intent uploadService = new Intent(context, UploadService.class); + uploadService.putParcelableArrayListExtra(Extras.IMAGE_URIS, uris); + return uploadService; + } } } diff --git a/src/main/java/org/amahi/anywhere/util/MediaNotificationManager.java b/src/main/java/org/amahi/anywhere/util/MediaNotificationManager.java index 619832283..cb98f61ad 100644 --- a/src/main/java/org/amahi/anywhere/util/MediaNotificationManager.java +++ b/src/main/java/org/amahi/anywhere/util/MediaNotificationManager.java @@ -48,31 +48,63 @@ */ public class MediaNotificationManager extends BroadcastReceiver { - private static final int NOTIFICATION_ID = 412; - private static final int REQUEST_CODE = 100; - public static final String ACTION_PAUSE = "org.amahi.anywhere.pause"; public static final String ACTION_PLAY = "org.amahi.anywhere.play"; public static final String ACTION_PREV = "org.amahi.anywhere.prev"; public static final String ACTION_NEXT = "org.amahi.anywhere.next"; + private static final int NOTIFICATION_ID = 412; + private static final int REQUEST_CODE = 100; private static final String TAG = "notification_manager"; private final AudioService mService; + private final NotificationManagerCompat mNotificationManager; + private final PendingIntent mPauseIntent; + private final PendingIntent mPlayIntent; + private final PendingIntent mPreviousIntent; + private final PendingIntent mNextIntent; private MediaSessionCompat.Token mSessionToken; private MediaControllerCompat mController; private MediaControllerCompat.TransportControls mTransportControls; - private PlaybackStateCompat mPlaybackState; private MediaMetadataCompat mMetadata; + private boolean mStarted = false; + private final MediaControllerCompat.Callback mCb = new MediaControllerCompat.Callback() { + @Override + public void onPlaybackStateChanged(@NonNull PlaybackStateCompat state) { + mPlaybackState = state; + Log.d(TAG, "Received new playback state"); + if (state.getState() == PlaybackStateCompat.STATE_STOPPED || + state.getState() == PlaybackStateCompat.STATE_NONE) { + stopNotification(); + } else { + Notification notification = createNotification(); + if (notification != null) { + mNotificationManager.notify(NOTIFICATION_ID, notification); + } + } + } - private final NotificationManagerCompat mNotificationManager; - - private final PendingIntent mPauseIntent; - private final PendingIntent mPlayIntent; - private final PendingIntent mPreviousIntent; - private final PendingIntent mNextIntent; + @Override + public void onMetadataChanged(MediaMetadataCompat metadata) { + mMetadata = metadata; + Log.d(TAG, "Received new metadata"); + Notification notification = createNotification(); + if (notification != null) { + mNotificationManager.notify(NOTIFICATION_ID, notification); + } + } - private boolean mStarted = false; + @Override + public void onSessionDestroyed() { + super.onSessionDestroyed(); + Log.d(TAG, "Session was destroyed, resetting to the new session token"); + try { + updateSessionToken(); + } catch (RemoteException e) { + Log.e(TAG, "could not connect media controller", e); + } + } + }; public MediaNotificationManager(AudioService service) throws RemoteException { mService = service; @@ -82,13 +114,13 @@ public MediaNotificationManager(AudioService service) throws RemoteException { String pkg = mService.getPackageName(); mPauseIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, - new Intent(ACTION_PAUSE).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); + new Intent(ACTION_PAUSE).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); mPlayIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, - new Intent(ACTION_PLAY).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); + new Intent(ACTION_PLAY).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); mPreviousIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, - new Intent(ACTION_PREV).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); + new Intent(ACTION_PREV).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); mNextIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, - new Intent(ACTION_NEXT).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); + new Intent(ACTION_NEXT).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); // Cancel all notifications to handle the case where the Service was killed and // restarted by the system. @@ -169,7 +201,7 @@ public void onReceive(Context context, Intent intent) { private void updateSessionToken() throws RemoteException { MediaSessionCompat.Token freshToken = mService.getSessionToken(); if (mSessionToken == null && freshToken != null || - mSessionToken != null && !mSessionToken.equals(freshToken)) { + mSessionToken != null && !mSessionToken.equals(freshToken)) { if (mController != null) { mController.unregisterCallback(mCb); } @@ -184,44 +216,6 @@ private void updateSessionToken() throws RemoteException { } } - private final MediaControllerCompat.Callback mCb = new MediaControllerCompat.Callback() { - @Override - public void onPlaybackStateChanged(@NonNull PlaybackStateCompat state) { - mPlaybackState = state; - Log.d(TAG, "Received new playback state"); - if (state.getState() == PlaybackStateCompat.STATE_STOPPED || - state.getState() == PlaybackStateCompat.STATE_NONE) { - stopNotification(); - } else { - Notification notification = createNotification(); - if (notification != null) { - mNotificationManager.notify(NOTIFICATION_ID, notification); - } - } - } - - @Override - public void onMetadataChanged(MediaMetadataCompat metadata) { - mMetadata = metadata; - Log.d(TAG, "Received new metadata"); - Notification notification = createNotification(); - if (notification != null) { - mNotificationManager.notify(NOTIFICATION_ID, notification); - } - } - - @Override - public void onSessionDestroyed() { - super.onSessionDestroyed(); - Log.d(TAG, "Session was destroyed, resetting to the new session token"); - try { - updateSessionToken(); - } catch (RemoteException e) { - Log.e(TAG, "could not connect media controller", e); - } - } - }; - private Notification createNotification() { Log.d(TAG, "updateNotificationMetadata. mMetadata=" + mMetadata); if (mMetadata == null || mPlaybackState == null) { @@ -230,12 +224,12 @@ private Notification createNotification() { NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(mService); notificationBuilder.addAction(android.R.drawable.ic_media_previous, - mService.getString(R.string.label_previous), mPreviousIntent); + mService.getString(R.string.label_previous), mPreviousIntent); addPlayPauseAction(notificationBuilder); notificationBuilder.addAction(android.R.drawable.ic_media_next, - mService.getString(R.string.label_next), mNextIntent); + mService.getString(R.string.label_next), mNextIntent); MediaDescriptionCompat description = mMetadata.getDescription(); @@ -246,23 +240,22 @@ private Notification createNotification() { } notificationBuilder - .setStyle(new NotificationCompat.MediaStyle() - .setShowActionsInCompactView(1) // show only play/pause in compact view - .setMediaSession(mSessionToken)) - .setSmallIcon(getAudioPlayerNotificationIcon()) - .setLargeIcon(getAudioPlayerNotificationArtwork(audioAlbumArt)) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setUsesChronometer(true) - .setContentIntent(mService.createContentIntent()) - .setContentTitle(description.getTitle()) - .setContentText(description.getSubtitle()); - - setNotificationPlaybackState(notificationBuilder); + .setStyle(new NotificationCompat.MediaStyle() + .setShowActionsInCompactView(1) // show only play/pause in compact view + .setMediaSession(mSessionToken)) + .setSmallIcon(getAudioPlayerNotificationIcon()) + .setLargeIcon(getAudioPlayerNotificationArtwork(audioAlbumArt)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(mService.createContentIntent()) + .setContentTitle(description.getTitle()) + .setContentText(description.getSubtitle()) + .setOngoing(mPlaybackState.getState() == PlaybackStateCompat.STATE_PLAYING); +// setNotificationPlaybackState(notificationBuilder); return notificationBuilder.build(); } private int getAudioPlayerNotificationIcon() { - return R.drawable.ic_notification_audio; + return R.drawable.ic_launcher; } private Bitmap getAudioPlayerNotificationArtwork(Bitmap audioAlbumArt) { @@ -299,19 +292,16 @@ private void setNotificationPlaybackState(NotificationCompat.Builder builder) { return; } if (mPlaybackState.getState() == PlaybackStateCompat.STATE_PLAYING - && mPlaybackState.getPosition() >= 0) { + && mPlaybackState.getPosition() >= 0) { builder - .setWhen(System.currentTimeMillis() - mPlaybackState.getPosition()) - .setShowWhen(true) - .setUsesChronometer(true); + .setWhen(System.currentTimeMillis() - mPlaybackState.getPosition()) + .setShowWhen(true) + .setUsesChronometer(true); } else { builder - .setWhen(0) - .setShowWhen(false) - .setUsesChronometer(false); + .setWhen(0) + .setShowWhen(false) + .setUsesChronometer(false); } - - // Make sure that the notification can be dismissed by the user when we are not playing: - builder.setOngoing(mPlaybackState.getState() == PlaybackStateCompat.STATE_PLAYING); } } diff --git a/src/main/java/org/amahi/anywhere/util/Mimes.java b/src/main/java/org/amahi/anywhere/util/Mimes.java index 35582986f..939689ddc 100644 --- a/src/main/java/org/amahi/anywhere/util/Mimes.java +++ b/src/main/java/org/amahi/anywhere/util/Mimes.java @@ -21,6 +21,9 @@ import android.support.v4.util.ArrayMap; +import org.amahi.anywhere.R; +import org.amahi.anywhere.server.model.ServerFile; + import java.util.Map; /** @@ -72,6 +75,10 @@ public class Mimes { types.put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Type.SPREADSHEET); types.put("application/x-quicktimeplayer", Type.VIDEO); + + types.put("application/x-subrip", Mimes.Type.SUBTITLE); + types.put("image/vnd.dvb.subtitle", Mimes.Type.SUBTITLE); + types.put("application/x-subtitle", Type.SUBTITLE); } public static int match(String mime) { @@ -116,6 +123,74 @@ private static int matchCategory(String mime) { return Type.UNDEFINED; } + public static int getFileIcon(ServerFile file) { + switch (Mimes.match(file.getMime())) { + case Mimes.Type.ARCHIVE: + return R.drawable.ic_file_archive; + + case Mimes.Type.AUDIO: + return R.drawable.ic_file_audio; + + case Mimes.Type.CODE: + return R.drawable.ic_file_code; + + case Mimes.Type.DOCUMENT: + return R.drawable.ic_file_text; + + case Mimes.Type.DIRECTORY: + return R.drawable.ic_file_directory; + + case Mimes.Type.IMAGE: + return R.drawable.ic_file_image; + + case Mimes.Type.PRESENTATION: + return R.drawable.ic_file_presentation; + + case Mimes.Type.SPREADSHEET: + return R.drawable.ic_file_spreadsheet; + + case Mimes.Type.VIDEO: + return R.drawable.ic_file_video; + + default: + return R.drawable.ic_file_generic; + } + } + + public static int getTVFileIcon(ServerFile file) { + switch (Mimes.match(file.getMime())) { + case Mimes.Type.ARCHIVE: + return R.drawable.tv_ic_archive; + + case Mimes.Type.AUDIO: + return R.drawable.tv_ic_audio; + + case Mimes.Type.CODE: + return R.drawable.tv_ic_code; + + case Mimes.Type.DOCUMENT: + return R.drawable.tv_ic_document; + + case Mimes.Type.DIRECTORY: + return R.drawable.tv_ic_folder; + + case Mimes.Type.IMAGE: + return R.drawable.tv_ic_images; + + case Mimes.Type.PRESENTATION: + return R.drawable.tv_ic_presentation; + + case Mimes.Type.SPREADSHEET: + return R.drawable.tv_ic_spreadsheet; + + case Mimes.Type.VIDEO: + return R.drawable.tv_ic_video; + + default: + return R.drawable.tv_ic_generic; + } + } + public static final class Type { public static final int UNDEFINED = 0; public static final int ARCHIVE = 1; @@ -127,6 +202,8 @@ public static final class Type { public static final int PRESENTATION = 7; public static final int SPREADSHEET = 8; public static final int VIDEO = 9; + public static final int SUBTITLE = 10; + private Type() { } diff --git a/src/main/java/org/amahi/anywhere/util/NetworkUtils.java b/src/main/java/org/amahi/anywhere/util/NetworkUtils.java new file mode 100644 index 000000000..c1a975c05 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/NetworkUtils.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.util; + + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.preference.PreferenceManager; + +import org.amahi.anywhere.R; + +/** + * Network utility methods to check the current connected network + */ +public class NetworkUtils { + + private Context context; + + public NetworkUtils(Context context) { + this.context = context; + } + + public NetworkInfo getNetwork() { + return getNetworkManager().getActiveNetworkInfo(); + } + + private ConnectivityManager getNetworkManager() { + return (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + + public boolean isNetworkConnected(NetworkInfo network) { + return (network != null) && network.isConnected(); + } + + public boolean isUploadAllowed() { + NetworkInfo network = getNetwork(); + return isNetworkConnected(network) && + (network.getType() != ConnectivityManager.TYPE_MOBILE || + isUploadAllowedOnMobileData()); + } + + private boolean isUploadAllowedOnMobileData() { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.preference_key_upload_data), false); + } +} diff --git a/src/main/java/org/amahi/anywhere/util/Preferences.java b/src/main/java/org/amahi/anywhere/util/Preferences.java index c4a953685..9089c5891 100644 --- a/src/main/java/org/amahi/anywhere/util/Preferences.java +++ b/src/main/java/org/amahi/anywhere/util/Preferences.java @@ -21,6 +21,11 @@ import android.content.Context; import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.amahi.anywhere.R; + +import static android.content.Context.MODE_PRIVATE; /** * Application {@link android.content.SharedPreferences} accessor. @@ -29,7 +34,56 @@ public final class Preferences { private final SharedPreferences preferences; private Preferences(Context context, String location) { - this.preferences = context.getSharedPreferences(location, Context.MODE_PRIVATE); + this.preferences = context.getSharedPreferences(location, MODE_PRIVATE); + } + + public static SharedPreferences getTVPreference(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context); + } + + public static String getServerConnection(SharedPreferences preferences, Context context) { + return preferences.getString(context.getString(R.string.preference_key_server_connection), context.getString(R.string.preference_entry_server_connection_auto)); + } + + + public static void setPrefAuto(SharedPreferences preference, Context context) { + preference.edit().putString(context.getString(R.string.preference_key_server_connection), context.getString(R.string.preference_entry_server_connection_auto)).apply(); + } + + public static void setPrefRemote(SharedPreferences preference, Context context) { + preference.edit().putString(context.getString(R.string.preference_key_server_connection), context.getString(R.string.preference_entry_server_connection_remote)).apply(); + } + + public static void setPrefLocal(SharedPreferences preference, Context context) { + preference.edit().putString(context.getString(R.string.preference_key_server_connection), context.getString(R.string.preference_entry_server_connection_local)).apply(); + } + + public static SharedPreferences getPreference(Context context) { + return context.getSharedPreferences(context.getString(R.string.preference), Context.MODE_PRIVATE); + } + + public static void setLight(Context context, SharedPreferences preferences) { + preferences.edit().putString(context.getString(R.string.pref_key_theme), context.getString(R.string.pref_theme_light)).apply(); + } + + public static void setDark(Context context, SharedPreferences preferences) { + preferences.edit().putString(context.getString(R.string.pref_key_theme), context.getString(R.string.pref_theme_dark)).apply(); + } + + public static void setServertoPref(String server, Context context, SharedPreferences sharedPref) { + sharedPref.edit().putString(context.getString(R.string.pref_server_select_key), server).apply(); + } + + public static String getServerFromPref(Context context, SharedPreferences sharedPreferences) { + return sharedPreferences.getString(context.getString(R.string.pref_server_select_key), null); + } + + public static boolean getFirstRun(Context context) { + return context.getSharedPreferences(context.getString(R.string.preference), MODE_PRIVATE).getBoolean(context.getString(R.string.is_first_run), true); + } + + public static void setFirstRun(Context context) { + context.getSharedPreferences(context.getString(R.string.preference), MODE_PRIVATE).edit().putBoolean(context.getString(R.string.is_first_run), false).apply(); } public static Preferences ofCookie(Context context) { diff --git a/src/main/java/org/amahi/anywhere/util/ProgressRequestBody.java b/src/main/java/org/amahi/anywhere/util/ProgressRequestBody.java new file mode 100644 index 000000000..703fff390 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/ProgressRequestBody.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.util; + +import android.os.Handler; +import android.os.Looper; + +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerFileUploadProgressEvent; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; + +/** + * Extension of RequestBody {@link okhttp3.RequestBody} to provide progress callbacks + * for file upload. + */ +public class ProgressRequestBody extends RequestBody { + private static final int DEFAULT_BUFFER_SIZE = 2048; + private int mId; + private File mFile; + private int lastProgress = 0; + + public ProgressRequestBody(int id, File file) { + mId = id; + mFile = new File(file.getPath()); + } + + @Override + public MediaType contentType() { + // Only for uploading images + return MediaType.parse("image/*"); + } + + @Override + public long contentLength() throws IOException { + return mFile.length(); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + long fileLength = mFile.length(); + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + FileInputStream in = new FileInputStream(mFile); + long uploaded = 0; + + //noinspection TryFinallyCanBeTryWithResources + try { + int read; + Handler handler = new Handler(Looper.getMainLooper()); + while ((read = in.read(buffer)) != -1) { + + uploaded += read; + sink.write(buffer, 0, read); + + // update progress on UI thread + handler.post(new ProgressUpdater(uploaded, fileLength)); + } + } finally { + in.close(); + } + } + + private class ProgressUpdater implements Runnable { + private long mUploaded; + private long mTotal; + + ProgressUpdater(long uploaded, long total) { + mUploaded = uploaded; + mTotal = total; + } + + @Override + public void run() { + int progress = (int) (100 * mUploaded / mTotal); + if (lastProgress != progress) { + lastProgress = progress; + BusProvider.getBus().post(new ServerFileUploadProgressEvent(mId, progress)); + } + } + } +} diff --git a/src/main/java/org/amahi/anywhere/util/RecyclerItemClickListener.java b/src/main/java/org/amahi/anywhere/util/RecyclerItemClickListener.java index 228fbe2cd..17affcd41 100644 --- a/src/main/java/org/amahi/anywhere/util/RecyclerItemClickListener.java +++ b/src/main/java/org/amahi/anywhere/util/RecyclerItemClickListener.java @@ -1,3 +1,22 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + package org.amahi.anywhere.util; /** @@ -12,13 +31,8 @@ import android.view.View; public class RecyclerItemClickListener implements RecyclerView.OnItemTouchListener { - private OnItemClickListener mListener; - - public interface OnItemClickListener { - public void onItemClick(View view, int position); - } - GestureDetector mGestureDetector; + private OnItemClickListener mListener; public RecyclerItemClickListener(Context context, OnItemClickListener listener) { mListener = listener; @@ -47,4 +61,8 @@ public void onTouchEvent(RecyclerView view, MotionEvent motionEvent) { public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } + + public interface OnItemClickListener { + public void onItemClick(View view, int position); + } } diff --git a/src/main/java/org/amahi/anywhere/util/SampleSlide.java b/src/main/java/org/amahi/anywhere/util/SampleSlide.java new file mode 100644 index 000000000..91996c1c0 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/SampleSlide.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.util; + +import android.os.Bundle; +import android.support.annotation.ColorInt; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.content.ContextCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.github.paolorotolo.appintro.ISlideBackgroundColorHolder; + +import org.amahi.anywhere.R; + +public class SampleSlide extends Fragment implements ISlideBackgroundColorHolder { + + private static final String ARG_LAYOUT_RES_ID = "layoutResId"; + ViewGroup mContainer; + private int layoutResId; + + public static SampleSlide newInstance(int layoutResId) { + SampleSlide sampleSlide = new SampleSlide(); + + Bundle args = new Bundle(); + args.putInt(ARG_LAYOUT_RES_ID, layoutResId); + sampleSlide.setArguments(args); + + return sampleSlide; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null && getArguments().containsKey(ARG_LAYOUT_RES_ID)) { + layoutResId = getArguments().getInt(ARG_LAYOUT_RES_ID); + } + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + mContainer = container; + return inflater.inflate(layoutResId, container, false); + } + + @Override + public int getDefaultBackgroundColor() { + return ContextCompat.getColor(getContext(), R.color.intro_1); + } + + @Override + public void setBackgroundColor(@ColorInt int i) { + if (mContainer != null) + mContainer.setBackgroundColor(i); + } +} \ No newline at end of file diff --git a/src/main/java/org/amahi/anywhere/util/Time.java b/src/main/java/org/amahi/anywhere/util/Time.java index ad66d1846..027187cac 100644 --- a/src/main/java/org/amahi/anywhere/util/Time.java +++ b/src/main/java/org/amahi/anywhere/util/Time.java @@ -19,7 +19,10 @@ package org.amahi.anywhere.util; +import android.media.MediaMetadataRetriever; + import java.util.Date; +import java.util.HashMap; /** * Time formats accessor. @@ -32,6 +35,12 @@ public static String getEpochTimeString(Date date) { return String.valueOf(date.getTime()); } + public static long getDuration(String videoUrl) { + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + mmr.setDataSource(videoUrl, new HashMap()); + return Long.parseLong(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)); + } + public static final class Format { public static final String RFC_1123 = "EEE, dd MMM yyyy HH:mm:ss zzz"; diff --git a/src/main/java/org/amahi/anywhere/util/UploadManager.java b/src/main/java/org/amahi/anywhere/util/UploadManager.java new file mode 100644 index 000000000..abea08e83 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/UploadManager.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.util; + +import android.content.Context; +import android.os.Handler; +import android.preference.PreferenceManager; + +import com.squareup.otto.Subscribe; + +import org.amahi.anywhere.AmahiApplication; +import org.amahi.anywhere.R; +import org.amahi.anywhere.bus.BusProvider; +import org.amahi.anywhere.bus.ServerFileUploadCompleteEvent; +import org.amahi.anywhere.bus.ServerFileUploadProgressEvent; +import org.amahi.anywhere.model.UploadFile; +import org.amahi.anywhere.server.client.ServerClient; + +import java.io.File; +import java.util.ArrayList; + +import javax.inject.Inject; + +/** + * An Upload Manager that manages all the uploads one by one present in the queue. + */ +public class UploadManager { + @Inject + public ServerClient serverClient; + private boolean isRunning = false; + private Context context; + private ArrayList uploadFiles; + private UploadCallbacks uploadCallbacks; + + public + UploadManager(T context, ArrayList uploadFiles) { + + this.context = context; + this.uploadCallbacks = context; + this.uploadFiles = uploadFiles; + + setUpInjections(); + setUpBus(); + } + + private void setUpInjections() { + AmahiApplication.from(context).inject(this); + } + + private void setUpBus() { + BusProvider.getBus().register(this); + } + + public void tearDownBus() { + BusProvider.getBus().unregister(this); + } + + public void startUploading() { + if (!isRunning) { + isRunning = true; + processNextFile(); + } + } + + private void processNextFile() { + if (uploadFiles.isEmpty()) { + isRunning = false; + uploadCallbacks.uploadQueueFinished(); + } else { + UploadFile currentFile = uploadFiles.remove(0); + upload(currentFile); + } + } + + private void upload(UploadFile uploadFile) { + File image = new File(uploadFile.getPath()); + if (image.exists()) { + uploadCallbacks.uploadStarted(uploadFile.getId(), image.getName()); + serverClient.uploadFile(uploadFile.getId(), image, getUploadShareName(), + getUploadPath()); + } else { + uploadCallbacks.removeFileFromDb(uploadFile.getId()); + processNextFile(); + } + + } + + private String getUploadShareName() { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.preference_key_upload_share), null); + } + + private String getUploadPath() { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.preference_key_upload_path), null); + } + + + public void add(UploadFile uploadFile) { + uploadFiles.add(uploadFile); + } + + @Subscribe + public void onFileUploadProgressEvent(ServerFileUploadProgressEvent event) { + uploadCallbacks.uploadProgress(event.getId(), event.getProgress()); + } + + @Subscribe + public void onFileUploadCompleteEvent(ServerFileUploadCompleteEvent event) { + if (event.wasUploadSuccessful()) { + uploadCallbacks.removeFileFromDb(event.getId()); + uploadCallbacks.uploadSuccess(event.getId()); + } else { + uploadCallbacks.uploadError(event.getId()); + } + + final Handler handler = new Handler(); + handler.postDelayed(new Runnable() { + @Override + public void run() { + processNextFile(); + } + }, 500); + } + + public interface UploadCallbacks { + void uploadStarted(int id, String fileName); + + void uploadProgress(int id, int progress); + + void uploadSuccess(int id); + + void uploadError(int id); + + void removeFileFromDb(int id); + + void uploadQueueFinished(); + } +} diff --git a/src/main/java/org/amahi/anywhere/util/VideoSwipeGestures.java b/src/main/java/org/amahi/anywhere/util/VideoSwipeGestures.java new file mode 100644 index 000000000..3744ac02f --- /dev/null +++ b/src/main/java/org/amahi/anywhere/util/VideoSwipeGestures.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ +package org.amahi.anywhere.util; + +import android.app.Activity; +import android.content.Context; +import android.media.AudioManager; +import android.util.DisplayMetrics; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import org.amahi.anywhere.view.PercentageView; +import org.amahi.anywhere.view.SeekView; + +import java.util.Locale; + +/** + * SwipeGestures Helper class. + * Implements Gesture Detector to control the actions of Swipe Gestures. + */ + +public class VideoSwipeGestures implements View.OnTouchListener { + + private final AudioManager audio; + private final int maxVolume; + private float volumePer; + private Activity activity; + private PercentageView percentageView; + private SeekView seekView; + private float seekDistance = 0; + private SeekControl seekControl; + private GestureDetector gestureDetector; + + public VideoSwipeGestures(Activity activity, SeekControl seekControl, FrameLayout container) { + this.activity = activity; + this.seekControl = seekControl; + this.gestureDetector = new GestureDetector(activity, new CustomGestureDetect()); + this.gestureDetector.setIsLongpressEnabled(false); + percentageView = new PercentageView(container); + container.addView(percentageView.getView()); + seekView = new SeekView(container); + container.addView(seekView.getView()); + audio = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); + int currentVolume = audio.getStreamVolume(AudioManager.STREAM_MUSIC); + maxVolume = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + volumePer = (float) currentVolume / (float) maxVolume; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (gestureDetector != null) { + if (event.getAction() == MotionEvent.ACTION_UP) { + if (percentageView.isShowing() || seekView.isShowing()) { + percentageView.hide(); + seekView.hide(); + return true; + } + return false; + } + return gestureDetector.onTouchEvent(event); + } + return false; + } + + private void changeBrightness(float distance) { + WindowManager.LayoutParams layout = activity.getWindow().getAttributes(); + layout.screenBrightness += distance; + if (layout.screenBrightness > 1f) { + layout.screenBrightness = 1f; + } else if (layout.screenBrightness < 0f) { + layout.screenBrightness = 0f; + } + percentageView.setProgress((int) (layout.screenBrightness * 100)); + activity.getWindow().setAttributes(layout); + } + + private void changeVolume(float distance) { + float val = volumePer + distance; + if (val > 1f) { + val = 1f; + } else if (val < 0f) { + val = 0f; + } + percentageView.setProgress((int) (val * 100)); + audio.setStreamVolume(AudioManager.STREAM_MUSIC, Math.round(val * maxVolume), 0); + volumePer = val; + + } + + private void seek(float distance) { + seekDistance += distance; + if (seekControl != null && seekView != null) { + float seekValue = seekControl.getCurrentPosition() + distance; + if (seekValue > 0 && seekValue < seekControl.getDuration()) { + seekControl.seekTo((int) seekValue); + String displayText = String.format(Locale.getDefault(), + "%02d:%02d (%02d:%02d)", + (int) Math.abs(seekDistance / 60000), + (int) Math.abs((seekDistance % 60000) / 1000), + (int) (seekValue / 60000), + (int) ((seekValue % 60000) / 1000)); + if (seekDistance > 0) + seekView.setText("+" + displayText); + else + seekView.setText("-" + displayText); + } + } + } + + + private enum Direction { + LEFT, RIGHT, UP, DOWN, NONE + } + + public interface SeekControl { + int getCurrentPosition(); + + void seekTo(int time); + + int getDuration(); + } + + private class CustomGestureDetect implements GestureDetector.OnGestureListener { + + private Direction direction = Direction.NONE; + private Direction xPosition; + + @Override + public boolean onDown(MotionEvent e) { + seekDistance = 0; + direction = Direction.NONE; + DisplayMetrics displayMetrics = new DisplayMetrics(); + activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + int width = displayMetrics.widthPixels; + if (e.getX() >= width / 2) { + xPosition = Direction.RIGHT; + } else { + xPosition = Direction.LEFT; + } + return false; + } + + @Override + public void onShowPress(MotionEvent e) { + + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return false; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + /* Check if this is the first ACTION_MOVE event in the current touch event + * If true then set the initial direction of the movement + * */ + try { + if (direction == Direction.NONE) { + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + if (Math.abs(diffX) > Math.abs(diffY)) { + if (diffX > 0) { + direction = Direction.RIGHT; + } else { + direction = Direction.LEFT; + } + seekView.show(); + } else { + if (xPosition == Direction.LEFT) { + percentageView.setType(PercentageView.VOLUME); + percentageView.setProgress((int) (volumePer * 100)); + } else if (xPosition == Direction.RIGHT) { + percentageView.setType(PercentageView.BRIGHTNESS); + WindowManager.LayoutParams layout = activity.getWindow().getAttributes(); + percentageView.setProgress((int) (layout.screenBrightness * 100)); + } + if (diffY > 0) { + direction = Direction.DOWN; + } else { + direction = Direction.UP; + } + percentageView.show(); + } + } + } catch (NullPointerException ignored) { + } + + switch (direction) { + case UP: + case DOWN: + switch (xPosition) { + case LEFT: + changeVolume(distanceY / 400); + break; + case RIGHT: + changeBrightness(distanceY / 400); + break; + } + break; + case LEFT: + case RIGHT: + seek(-distanceX * 200); + break; + case NONE: + break; + } + return true; + + } + + @Override + public void onLongPress(MotionEvent e) { + + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return false; + } + + } + +} diff --git a/src/main/java/org/amahi/anywhere/view/AudioControls.java b/src/main/java/org/amahi/anywhere/view/AudioControls.java new file mode 100644 index 000000000..913d7888e --- /dev/null +++ b/src/main/java/org/amahi/anywhere/view/AudioControls.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ + +package org.amahi.anywhere.view; + +import android.content.Context; + +/** + * Audio Controls view. Does the same as {@link org.amahi.anywhere.view.MediaControls} + * with a couple of modifications. Controls do not auto hide after 3 seconds. + */ +public class AudioControls extends MediaControls { + public AudioControls(Context context) { + super(context); + } + + @Override + public void hide() { + // Do nothing + // Done to stop auto hide of controls after 3 seconds + } + + public void hideControls() { + // To hide the controls manually + super.hide(); + } +} diff --git a/src/main/java/org/amahi/anywhere/view/MediaControls.java b/src/main/java/org/amahi/anywhere/view/MediaControls.java index 5cd102727..2a29fc2bc 100644 --- a/src/main/java/org/amahi/anywhere/view/MediaControls.java +++ b/src/main/java/org/amahi/anywhere/view/MediaControls.java @@ -21,106 +21,28 @@ import android.app.Activity; import android.content.Context; -import android.os.Handler; import android.view.KeyEvent; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; import android.widget.MediaController; -import org.amahi.anywhere.R; - -import java.util.concurrent.TimeUnit; - /** * Media controls view. Does the same as {@link android.widget.MediaController} - * with a couple of modifications. Media controls do not auto-hide, back button do not hide - * controls but finishes parent {@link android.app.Activity}, there are methods to show and hide - * controls animated. + * with a couple of modifications. Back button do not hide controls but + * finishes parent {@link android.app.Activity}. */ -public class MediaControls extends MediaController implements Animation.AnimationListener { - private Handler videoControlsHandler; - private boolean isVisible = false; - Runnable mHideRunnable = new Runnable() { - @Override - public void run() { - hideAnimated(); - } - }; - private long autoHideDelayMillis = TimeUnit.SECONDS.toMillis(3); +public class MediaControls extends MediaController { public MediaControls(Context context) { super(context); - init(); - } - - public MediaControls(Context context, long autoHideDelayMillis) { - super(context); - this.autoHideDelayMillis = autoHideDelayMillis; - init(); - } - - private void init() { - videoControlsHandler = new Handler(); - setOnClickListener(new OnClickListener() { - @Override - public void onClick(View view) { - hideControlsDelayed(); - } - }); - } - - @Override - public void show(int timeout) { - super.show(0); - } - - public void showAnimated() { - if (!isVisible) { - videoControlsHandler.removeCallbacks(mHideRunnable); - Animation animation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_up_view); - startAnimation(animation); - show(); - isVisible = true; - } - } - - public void hideAnimated() { - if (isVisible) { - Animation animation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_down_view); - animation.setAnimationListener(this); - startAnimation(animation); - } - } - - public void hideControlsDelayed() { - videoControlsHandler.removeCallbacks(mHideRunnable); - videoControlsHandler.postDelayed(mHideRunnable, autoHideDelayMillis); } public void toggle() { - if (isVisible) { - hideAnimated(); + if (isShowing()) { + hide(); } else { - showAnimated(); - hideControlsDelayed(); + show(); } } - @Override - public void onAnimationEnd(Animation animation) { - hide(); - isVisible = false; - } - - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - @Override public boolean dispatchKeyEvent(KeyEvent event) { if ((event.getKeyCode() == KeyEvent.KEYCODE_BACK) && (event.getAction() == KeyEvent.ACTION_DOWN)) { diff --git a/src/main/java/org/amahi/anywhere/view/PercentageView.java b/src/main/java/org/amahi/anywhere/view/PercentageView.java new file mode 100644 index 000000000..666efc5c8 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/view/PercentageView.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014 Amahi + * + * This file is part of Amahi. + * + * Amahi is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Amahi is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Amahi. If not, see . + */ +package org.amahi.anywhere.view; + +/* + PercentageView. + Custom view class for displaying volume and brightness controls on screen while using Swipe Gestures. + */ + +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.IntDef; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.amahi.anywhere.R; + +import java.util.Locale; + +public class PercentageView { + public static final int VOLUME = 1; + public static final int BRIGHTNESS = 2; + private ViewHolder viewHolder; + private View view; + public PercentageView(FrameLayout parentView) { + LayoutInflater inflater = (LayoutInflater) parentView.getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = inflater.inflate(R.layout.percentage_view, parentView, false); + view.requestLayout(); + viewHolder = new ViewHolder(view); + } + + public View getView() { + return view; + } + + public void setType(@TYPE int type) { + switch (type) { + case VOLUME: + viewHolder.icon.setImageResource(R.drawable.ic_volume_up); + viewHolder.progressBar.getProgressDrawable().setColorFilter( + Color.BLUE, android.graphics.PorterDuff.Mode.SRC_IN); + break; + case BRIGHTNESS: + viewHolder.icon.setImageResource(R.drawable.ic_brightness); + viewHolder.progressBar.getProgressDrawable().setColorFilter( + Color.YELLOW, android.graphics.PorterDuff.Mode.SRC_IN); + break; + } + } + + public void setProgress(int n) { + viewHolder.progressBar.setProgress(n); + viewHolder.valuePercent.setText(String.format(Locale.getDefault(), "%d %s", n, "%")); + } + + public void hide() { + view.setVisibility(View.GONE); + } + + public void show() { + view.setVisibility(View.VISIBLE); + } + + public boolean isShowing() { + return view.getVisibility() == View.VISIBLE; + } + + @IntDef({VOLUME, BRIGHTNESS}) + @interface TYPE { + } + + private class ViewHolder { + private ProgressBar progressBar; + private TextView valuePercent; + private ImageView icon; + + ViewHolder(View itemView) { + progressBar = (ProgressBar) itemView.findViewById(R.id.progress_bar); + icon = (ImageView) itemView.findViewById(R.id.type_icon); + valuePercent = (TextView) itemView.findViewById(R.id.value_percent); + } + } +} + diff --git a/src/main/java/org/amahi/anywhere/view/SeekView.java b/src/main/java/org/amahi/anywhere/view/SeekView.java new file mode 100644 index 000000000..3abb5fff7 --- /dev/null +++ b/src/main/java/org/amahi/anywhere/view/SeekView.java @@ -0,0 +1,44 @@ +package org.amahi.anywhere.view; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.amahi.anywhere.R; + + +public class SeekView { + + + private View view; + private TextView textView; + + public SeekView(ViewGroup viewGroup) { + LayoutInflater inflater = (LayoutInflater) viewGroup.getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = inflater.inflate(R.layout.seek_view, viewGroup, false); + view.requestLayout(); + textView = (TextView) view.findViewById(R.id.seek_value); + } + + public View getView() { + return view; + } + + public void setText(String s) { + textView.setText(s); + } + + public void hide() { + view.setVisibility(View.GONE); + } + + public void show() { + view.setVisibility(View.VISIBLE); + } + + public boolean isShowing() { + return view.getVisibility() == View.VISIBLE; + } +} diff --git a/src/main/java/org/amahi/anywhere/view/TouchImageView.java b/src/main/java/org/amahi/anywhere/view/TouchImageView.java index 506b741f3..78fd2a325 100644 --- a/src/main/java/org/amahi/anywhere/view/TouchImageView.java +++ b/src/main/java/org/amahi/anywhere/view/TouchImageView.java @@ -99,15 +99,15 @@ public boolean onTouch(View v, MotionEvent event) { float deltaY = curr.y - last.y; if (deltaX != 0f || deltaY != 0f) { float fixTransX = getFixDragTrans(deltaX, viewWidth, - origWidth * saveScale); + origWidth * saveScale); float fixTransY = getFixDragTrans(deltaY, viewHeight, - origHeight * saveScale); + origHeight * saveScale); if (saveScale > 1f) { matrix.getValues(m); float absTransX = Math.abs(m[Matrix.MTRANS_X]); float transXMax = (origWidth * (saveScale - 1f)); if ((transXMax - absTransX < 0.5f && fixTransX < 0f) - || (absTransX < 0.5f && fixTransX > 0f)) + || (absTransX < 0.5f && fixTransX > 0f)) getParent().requestDisallowInterceptTouchEvent(false); else getParent().requestDisallowInterceptTouchEvent(true); @@ -216,7 +216,7 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // Rescales image on rotation // if (oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight - || viewWidth == 0 || viewHeight == 0) + || viewWidth == 0 || viewHeight == 0) return; oldMeasuredHeight = viewHeight; oldMeasuredWidth = viewWidth; @@ -227,7 +227,7 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { Drawable drawable = getDrawable(); if (drawable == null || drawable.getIntrinsicWidth() == 0 - || drawable.getIntrinsicHeight() == 0) + || drawable.getIntrinsicHeight() == 0) return; int bmWidth = drawable.getIntrinsicWidth(); int bmHeight = drawable.getIntrinsicHeight(); @@ -241,9 +241,9 @@ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // Center the image float redundantYSpace = (float) viewHeight - - (scale * (float) bmHeight); + - (scale * (float) bmHeight); float redundantXSpace = (float) viewWidth - - (scale * (float) bmWidth); + - (scale * (float) bmWidth); redundantYSpace /= (float) 2; redundantXSpace /= (float) 2; @@ -263,7 +263,7 @@ private enum State { } private class ScaleListener extends - ScaleGestureDetector.SimpleOnScaleGestureListener { + ScaleGestureDetector.SimpleOnScaleGestureListener { @Override public boolean onScaleBegin(ScaleGestureDetector detector) { mode = State.ZOOM; @@ -284,15 +284,15 @@ public boolean onScale(ScaleGestureDetector detector) { } if (origWidth * saveScale <= viewWidth - || origHeight * saveScale <= viewHeight) + || origHeight * saveScale <= viewHeight) matrix.postScale(mScaleFactor, mScaleFactor, viewWidth / 2, - viewHeight / 2); + viewHeight / 2); else matrix.postScale(mScaleFactor, mScaleFactor, - detector.getFocusX(), detector.getFocusY()); + detector.getFocusX(), detector.getFocusY()); fixTrans(); return true; } } -} \ No newline at end of file +} diff --git a/src/main/res/drawable-hdpi/movies.png b/src/main/res/drawable-hdpi/movies.png new file mode 100644 index 0000000000000000000000000000000000000000..03ef142b00b86c603a9973e25c3c8b2f224d6cf7 GIT binary patch literal 25061 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 I%YlF7zhkW!C;$Ke literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/music.png b/src/main/res/drawable-hdpi/music.png new file mode 100644 index 0000000000000000000000000000000000000000..325ec9d7e9ed8711169afc4a58e35a9b71427ae2 GIT binary patch literal 43519 zcmYIw1z1!||L`m*NOP4G2^ADnIwe<7Nd-xf6a=INmXcZnQB-0%QQ@XK04LzDC%`Ep_``?!>I?XT$V6HG zCOAU=iz`nJ0e}rC-n^md7BxTQ9;)vgeRR~br|p_rWOG9H$0ZV!ZYcKJ1x4|KYgQ^v z1ofPPc0W(?7}2l~F&p2uVvx@}e?`XPL=mI&Ia?K1KVzZio$I*Qq_4^1esDI{-4#*r zODbL5+mcuNPjNaqU3yDfign5@pf-0-TAqJ>%3#SOhXeo`gU#XY^r67OvrFNJD+hG% zMc>v$j;{D;-d(lgO6S+f@{nvu{5))u6WYfRB7h4FOs-4H#a&^4?>&)mKWjYkm9}{) z`_e$;(aLp2LR?`3P2_-fn4{y!V(^CEIT?Gp1xJlonx)YBlhqYJX>A<>i!`5>RXcvr zOTV<#kCU>iUyOG~0c>zy8w!9V1E;0__L#X?_WU;~)1dxxu~7S?F^>0Po@F+zGDW?$ zrF~+G&d>7Y2F_bH#`tX0h1tGI;^wh+-uYOUc-d^_%Xos+8je=K03yKF z%mBH*Ov+QX_Nt-1bW`T@j|H`fgr9?+2}y5osYbyqiRDg?r2tv zD|9>(yyI+{{)pJ&v#Ny0jn!I}-RM&9%QFtQo?iQSz+>7zCj5JIZ@zwQ(owpp_S0#^Rjgq`~B!FD3egQ<$*vK(TAWE$XDKFr^!XT|iqz)2Z1?v3=X zlzO)cLM2On4cD`M=Cz96nMo`018N@&68+2gU1h(n@N7>{tcZojv={*F-r)q`M9_v!fzudMz#A#)dXs4OPDRhe zz|)(fHQ2>f1=m}2Lt27ki+lMW-^=--Km#7!4Ks<#2j%&W^Ysobcc0ep9`ZRdUN#{`0pDER_54yY^dhM3R>t)ivaYD2^4)@hFSE2-_(`$6osdWrFU^c2In@P zeRsRt(b|j&8(dK-muF7D$miCr#XBsuf{)r&D*A|57q8cQ zpAYol_7|Zff8>p^9V@etl}CXNM1;m_d0U0r3xnzoR#EvOO&DAfBQE9DB0)pj61y#? z$KYPgQos7adZ4}JPQqc^J%AlIg?PE$daI`=s6lzwIl(>qyForj&omEGXTA-Z~dJ!$K|2JtTd@cjRAwkbd^2~^c8i@umZvS;o!H01sqaga=@ zSw;I`g$!HiME|RkTfUS8fSi>O0EO_kOZR0`N{)PsJ`G4J+JC^Hr!N7sF-ye}*EMJJ z@THun>qo_HHvslKf-qOxVCm*pN24!OhTf4}?#^)TM2jJxyZw9#M}uZT&Zp~3!ELty z)(e?T61p&-=~iJfz<4jyT_E_TFAS+;5syy-`GpkS}U)p1}tfK7> zP@9GR5dCc5q5P=Yez*0b&=t%Xz~059SX9z?XILly)xZf5$A`>eK^#+maq%p-WBI^@ zQ}t6BK>qt~$k2t(o}wmF2ibH*I~8mE>kG7 z3q@oGR-vH?pMuyIKCTmr9`zSSq)NCMLdV4fx!?OXdB{32aF931^38aN>=po=i5V%iKd-a-vAUg z$ru91eRR%};uon@TXj`D%LJ(jqSu?)O}XVg=CAwKn?;-m$U>^)Y`Eu&%{()okr_OV zEoNng^VHO~O6Ojcd^>79j#elvHDo-e1hhH9t;d-9q#VulL{~hrm}UT+LQpJ94?1f$FWOW| zUs>ByI7^Sm`IWQ5%Vj6i$%h^yN9qgXFRlyYQJcxR8dxl3q<(9f1)#5xD-1sj zmM%4!Oc+LZVvm4*Hy=G`jSZK+FjLni1+bcXS*3?r+1aCS`$DER5T|3BYxkQ_@ObEt%M2Zilum!uF`Sg-!A=ul`A=oafnOQM|6lMJiw7MArRdK*J z?`yGGh1~|@TW=aT?G<#%Sj~DD7w!4ay)G5SKAMuvtbn@4z;3g+4`84akjX@O9<0mwM3t!i>C7PUy8y|T;ODciX0`ebgj(en3UL9+chdS}Q<7?p!I-{A6>+yO}{?uVMFRpIYYf((ukuwvf3?>aj(4}nfGLV`f6-2O${bsaKMf_6r~Jru3am<`bhjd2F7<3|0$q`foReU zvr-+}=_p08h!exu@TBzT1uy>_0BZsT757AJn-K7c`J6k^6y@`_NfJYe?SRIIK0KKD zlj9RQzuk5nCT6eZSOWJoGR%4QDPI7vQv)X$eqAqY#uTa63tz>M0gz@k%s7SN$e}tB zqzYk<-_md$u+do0!L^L>JvnF&Gtf;`#p!qSNoQY4G*@K( zpov%mn06QRy3M_}AE3tiZ^ayAmO3s(tKOs5)xuMQjN!n1mJ^nY7q z!!tCp4IawBV~(#0v$Be!*LF(?Xmhp$m+<*d5Mo}lN0aBSV@@)*%-G^vSJ_D z*)q>*Y3VfXzkpF0x&&-lAJ?avx+@|7J5fE(ZHD>|1-=KmQldW8>37z)=ietWflGZPS2Os7D&^nBoa&up~B zZ3Cx&*2XDG1kj9+XDuVG?Rpd==t@pPqiG>rbg578BkT5~M5)~;2iViygTGzgg?qIm z=Rq2T*$+;n5SI-6F%aMx~fej=)kkEgGy)V_K>Z82C26 z16jTL7S1zyP$Drry4tENegPp(A&Z#Z>#^C-AFQt^Q9&{YEQh`t;hg&MgdT~jPW!AxN624%s8Sz(^5?V9iZs+8grM1#Z+U}lQ8 zJ4$(XFxC*h77L@7@nd9SXIJM6i<1$LrQ2d8-ku{PtEyN_I)7n(e@b1Cqt5F3hTX}_htSY@&icb5wK z2@qsdcrV!Ci{s?}$V(nng8_{=;Iv-s9vs^Iu77Dw!(kh4!%=T>|J+eha_ItLyfW6Y zmIzQQAaU+mYR;P%GEjY4WzIeEI7+qPbrZszQVZw>fZ*|>WhGz3j2(W1iHRoCH6xL& z0wV_T6P)rkFv-H)+KQ+r^sivIyo(3{`(8q7@Yh7z+Ssu5MZ~26l8udi{Ug8p_sTI;%mkqV z&>B#}&PB~4eLf1}l22d}hweBSHuYFa7o=N%Nr_UTB?NJRuBgzQGFHOc;6=?i3?zYa=6j{h&xO^(A7Y3Td%7=_;*{AEHCb;QH64mlfSZ-QmlV_%@y zJ~U05F8<*C+B*gdtYpjtNsLUOicjBLZ{X##D|>m0Ylyvo&@)c%(sgQKVX_}7B z78-(F!ThFh>aTn2+=?2a763>{~d#>nn3_xymqrcyu%3L+3haOIX@~LVpSReM(pI>opI+(_50|QZy)V*`u zhQne<-`lv*xF=*_`84;C?b5oKE3qGRHdq?LXljem(#WU%bbfbHxR(?F?P0i5ls~oY zO&7nN&YcW^3?3-bDv4AXMmU)tt?u2=bdATV%{q}5YE>W!CDni3ySriMW5#f zXkESTPH**jt1!%CM75!KUO2SSXWQwoXFvgRN)iIIjN;O{vrvG*-q zVI*|M7SB&=oL7&_!r=g|1#cf+$q7yv<~W(coL$dF2D>VV3m_&0PX8@})tKXE!k(NT zsIC$OYIGkrF;O3aUEltMh71npIm4YrM=A>Wp&gTx1GUwN(*FG5Ju3D)NLB$uMW4|< zPirStdF5&9xVCR60ms}A3H#6IikwC(8=Y_9=}mg(H=9Hp*ITe7EPH zRsir>Kl9LQPL@?GRGYwpN&|=3w6MC!(UY8Es*mU@}`fJ=< zp@%?HY4x4-+OJ2-oh)OsM-dCbm zm_zDs$vo)(y7>#402L~|V7;)C+r_yaV|=_vR()>u5cd$XJqa@b`AI^M1j(+Iam$S4 z_WGq(Zr6yysLjMM7z#Tio8t4thPc+Uk4H76!V9xqz8!Z1zr!Vg?%KlXw8 z8@f(M3V0yKIfK$V15a9$5tY-xmOq5TFTrS7-xgpONN%eSz!AiP`nR>Sf=hGdCU{0~EkOTIcPD>G;gZnPWyq$5o(~@PO+tYsr zD%5GX0eWr0*5Yb_UC2LuVQalqL+8r7Fe7#qa|-J14PjT;sMNY{#dI=6{{fAH*SNQ` zlg>aw+j&l?v(ze1I(sK^KFS@R@j8Hh_8zAT?V}bN7*a`~shg!1=o>JJNh)}*#wiFm z9+_yU4vhFw-f%Yo*aYiE%To1hre8)raC=S}MyEY>C)YiWcsVt1F>L&2sNB-$i&S4N z!cjmVpx7h4RbapGa&cZMrR#*h(K#cO8Vs{xgtgN$HJHMGJZdf+up1Gi@@-&ekB!Ce+~2aKO#0(Gpe z)-yBP*ATx9jFaYBBT9R`FJVq9!EWUE9k@}dL5Ua!#!R4gx=E%V2GyB1MowmOSpCDe z(xX(uemtn8?_voV_&Z)g?JNT@{)IR&oHR=bjFW)#-cc6}oPqHdgS;hAP+#mA>~u*U zlx930B-F{Yx$kyo`V>+Mk?Sez3fBSvgc?EhRz<{HtIx?d3 zS%72UeD$KsA_a!J zSF(@-1!6G{RaOU6ZEH={G@hWx!F>7{O5C|whojv_d+SITvFm?vgYBzfn8Lt}hH01M zgdLWjXT!X$?qMeJnfTE2N6-!UocFM|*3$BQP>oj@1&)xr_jnFBVQZ;su5^W-0n&%K zrQMjUZ;ILZ_zVRwpgIR?7NWM_XIfle`>VSB7Uko(J?`JCycy8OjPP`|>T6Pt$L>bF zV9+^oz@})mXjeHn$U{1W5X%;*!fLl@Re23VgnQ-(WSga!m%{eKs*Mm!L325ZdW@v& zl2y=&pLpo-=c>~kDO-EtqDYPvEHxaD!)%mVFG6woKqCM00Z!BAa_hSq=ML1=ZSlqp zPHyRsx6e=HPaR#uocdu5)KYksEG4c?bzhyq;!OtlPp)vLc7}=acyeM+`3L%ekMURS z^gN7PY|HVf9GEb7jB3vcb1ibD50(CN;PB56EzWEK|u^Rkw=(rS?(-i=VlC{HcU zM<2FA8{tCNB8towz8wXF3&+9f_4wqR3-G zS5@4p)7mak79XgEN{MjtUZ00vhRMj#`I)bHM-JFw%TDS_qP>NL#8ufMq(K_`xx(UV z1)HuM-r0efza7`T!nu#2Vf%guaVMZw8(r$#J9cXd7x(-oV+sRbWup&QsH;wQdU_1> z`P-DUce_A20&<{P}_V!CUPPswS@JCMi=!Qs?5^)i1 ztA5net2H0DJ+=onnVQ=0?Mb8&ehG7G^#uw|QukD3^&YL!8P$EnKLr`pb}c^rD>L4* z=|%#TfMB;I)kikZ^(CJ9YEK7k9zqw9*T&3nJw=SGXK=_NW;|=OQW-(b_e|udYvyCI ztuh@8loZs$pFg<)morQR#R8rFp%*7s z!szYUL9O$^qL#pn3MOV{Qidz zD7KIY@=FyWm=i>PdO$s{ICbp6@kr_4Acx)NuvR{uCpU(8^F0dv zL)baT{RLP4z17)c`}#sJajl`up}NU+kV*W6M=duX>2|c9yRWfsi_aLr>Fky<{ef!N zOx82VIABW~|VuO^4*HR_e-;?5F0dViQycMF;Vu(6&)n#^lO-H4R|R{dySuwGa~-+y5(v?3OZqwR;3|tW8fAw%m7Sb1<|%rVbL>ym zjLCFFuFaofzSqJ4P81Z(O>zZKcwe%#Io2i4FWki{o}*s{TfD?D3dN&zSTu4v7d}zJ zea!HNpW7=p~&LZ?GgdLYwYFk);C{J#T z;1wGCtk-68R+r5CoOse8^b>i<^}bA)5C`5Gz=_q$uf>OF^6&+}`rU1!lHSq5)MGb; zc9$@bzx8{y?Z1gv6xa(^Gc%v27DbO;U|;!5AxJm=E&%q#6W7R6tIg*d@p26(39%Q7 zM+?6aQ)*UMhQi}5BxwfkH3gt z{dm^SCl;37_yh|*`i<@@#yN^rJn`HUGW0X%OD^#j@eMLC6wA%$1d-K6YK*71sf)%u}p zo4o$`@)3|nDw|4*F8j7IC%;0WS%kM-JkfUV|0&sqioNxsm52TGJM|yjzm6 z=Jg=G^T+a2Q`nQw^}2$&*`JHzBjA=|UK>1^{HOhtCi(d~tq-2)O$QX106vwzmhRmO zm5Fj3CB7g~TTB;Ve9x+t;m^62>XRkYGP5&O+9mo$!k7^&=@BPVw6Se7wr6yT8F!mD zp0LA8K4mrb9l|Sg`){3qj;`E2>xNzuahoRZvoh(ooj)XQ?k=^uXI|cm=^T?NoXedm zn(c{5jDvJ*`Tb(mO{r8P$phkp?KghwgLV}PC)CYd@4AeBr65}*W5$V)gI7q9 zm|Rx;AS3XvnoYi78cdnVoK?T!uzmXYLY+MMt zKuQsOfFa@I;wDw`x;mwRGF6(EGcsAoT64twOjA6Yfc9h{9)NV|M?ulw~q+7 ztBlCL{)n|VO;30JJ7@9v;Ct*`%Vk!QXhP$W&3sfAZhg4zyybVIDmLMbf@a|&XFLtE zOd47T^dqyZ>N0l|Gt%WOCWf|HN>qzvb_d!Rf#EG1mrdxJ?8t_KVGoSxFA~vycP;HE zCtbv+sx%t$F+XC7c+w7@d906JW+9CxFlJRLu~%NuxIE~#dP}luObaS2Ti3hcuPQA_ z1{|85u5z3B_rZ?d!HcrII2mqW_E5##}bjH*-k>eZ`BM6SHc3AE61>n?A%mC zIE)P_^G`MqRef{KH76+oZ%8{qzfzYk_iYaGPUpM2&h8uzJG!H;)DhW@mN ze22^X+FpwmMSd;di>kN1t=&FJUro)kHV#EtEqL&IPod}~kkXHu`s#kYdVGV8yDDY2pC|rEmr85k_%)1590pEe^EGunC9Si2foo4C;8<^EajHHx9pL_5_7yD zm>>A~Zr8};&d<{+&b&W~s;)AqVcsKt1WWMHSEk%h;t&n3Z}dGt=e=)rpSa8c+urP! zy}&IzWkAAQcido9h6y=i&s+{miS+g;TJ*$;zDJSJ%R6}XuL`ytSWXAAKP3FafmA2g zR$E^qD#ma@d%Ll=eeaG5JaRFjk#Dz{kd}i70hhQKK-sUqAsL6-i}v^o8~2{}a8<;4 z{$(2pAey=Z`#5iP6td#waL?^ajJgI(T?!mm2nCoKkYt;k@aleVq)+jfha@pj@H@)L z%5t+k?iS;)6j*xQdF5yXy4hP=Z}RGA&WRiQ+OSnkdak2KbeUhV=Ov{gfe2vtD$5va zBV8L&I4&GX18KeAX$}x>P1mYQ(ufmQ{gIq1K1|dW=5fD(QFv?v6w-w%`>#WWsX5;N zD<8kyLmhM_kz2$0LlTuQB}vx2DEPQh5KB|)ec??ogw9n%D&s%n!*cLmL=vVSASoYzh#oVqi*YH`L)P0GJb%iphCC`O`npY zldRuM&2Ay%dY6YnGSaHA5!Y z_)&|1@Ee$zV3Sv%PfthaS}%1;BDIneVbxGqXD~%H1T2Gq=j2^%P`Xl&LMz$vb`K#}46}@VyE9w{kPekWtwx?jAz&YKbbT_>6l_6&iR!xSoo*FpHkh1{b2VREb?Z0tKR+z}v)(M=auSdwGO(tZ~Bj6-5p~z!^?~ zP08MLYEoL$q5x_V78Uza!An0M3!n1HzzOGVy3&^b&4SE&kv|hZVzrRC<=X-;ZJ z6THWYV9MVUa`53m<6rw-?n46wfQMur&=p}T9p_&vitl|f#i$+h!XOF{vf%lFmd|ew z_91FwsNf>OEx;rE(k9#LJsc+nD>niWDly6=4 z#ihW68_UiYewIo~i9Xhi2!DunfqyGe?`cm9#Sp({+5q6|taY0s1qv`_+9;BbP2e&u zz6cLq<9Y`VOmW@5iwypnw%}T+S2LGmGt!ldXVw(eW{r`?pFp9-EDF~P?ocuF)1;jR zzwTiiris9*h0iNzHvXPDTVNz?FAJmMsVjw=Qn5V3gji+KEQ$La5(o(jIU`4G zH5tuhQz5;fQlbY7v_)A`e3)9}WOJZ)o$pC-QUgq3s2gCM?kEkA)@4*~W`&v#lI=8o zv=(6{_2Ps7$OYb^t_K%qUcfAnE%axtN?U2*bzdZUp?`duzu4E3qwTlI@J@68l5U@o zdqonVEQEe)PpfBUs5T~@v>W+xZ=Z2GZ z8}8xT4PQjfS;?aL7~Ze~p)E|AZgvmCVCojB)e{Rw8m!QO;q4d~6L_^}zU{x9ODlu` zIi7(W`TOgQpVcnP5AF)HTeeT@N3x=25jUy8N9RJ;MRTP3MCZOwOYeSe*@<`&VBBW& z*NmyB>=LHBg8eD@!+3-}J$wrIE^l(>XgH@_z`#o}fQT0!J<-1m=nebDlARCB_+q_; zqo0IW*-rv4&r?(%Z9qT9<~2`9KkP1NHtk=oI1h|#1+~b`#QC5rrU_Au5Z&0wYda6O zVjzkY%3iAaHCz+Zi^7KcaAF7n%|Py%OLKpA9T2Au_-qcpx1IC|sY6wDv=sIzD(zpT zfdN#dBiyfC1dq<`$!6GLJ+q^1aDf`1Tx|K6Kn+PHYO{0Ui;x8I1juHW7 zQJc?BGg44ualLnC9$QT#UjmFjYaEU(cQUY@4hT~o1XWfp70MmGQ2uHvP+GIo2X2ed zF3@indWsT#9Hw7~DG!6HgNyM@aFb!a6+Xt+L)g#bWHkTyjdnAVKmxgZae@ z*RyTi%(@NG{9RhwGk=~MdcqV(EnP*aNmukgLTt+W1^G`x5{N7YLUZ@l)_ve9j{2l{ zezNP(!+61@m*gNJs$70l)EG=mE%Tp|y$pfJeMt}#U5;t{Po01W=(gVG@v(<_*Qvj4 z?PJH|RZZ(#j#P)quFEL2QS16`#T*i7w$*gu5CKGMfSIw9@>X-cto#X9!1-|L)(Nah zxSB>z11C(-;Bd9E!*afT8>)&UdSMKv#(5|pdp$0N2Lo{j{=br|p^)YMY)*-T&*^Qa z6@||;L9;uso|VI6=Qca;ZCHo>QUtyYGq3Lg>b93wA_&xmH>Jd0Oz1DDstGAudBq6E zSvc7LDSh^sr58fXc^69Dl|IiAGlB6(OQg=WK;d}_G(1FVYWn7M#1dqFr6;R4T)$^i z01`E$YGl{A!B3LCx?*)8uGn><9y+{lFnzw|oFZ_H?b0XYGAotCUp7MU)@jP`8xLSt zG^5$Po(b2Uy12?`F`96+SqwY!V|lK0dnF)9h!M}DiTe5dOpCXNjmGV~VrJ?SjJQkm z$HS%wShW<&p?2pV?@WverYpT7-flq2LDkgF|GaH6mOu&M-Pi{gJKd|xE+Owh{g^V+ zWusm5zr_qRH+9OIlllKQMp7^m&`di;v-TX0ePWzD!mm)k%^OsgJ!mHa2)9dxQt$U5 zvH@)MO~Sm$(X5F<&SOZ^*bdIh-&qR3=Cs{&wN6ccdRvy2S`?3QLS!>OO+b5N2@2yV0~`tvofp{;(hO z52doxt};aoJzU%1Pqo&oV&4jw?ZbylSOz&3isw~dMpXs)=UOP9HJF~m4+W`@xkzfb z-^P!j$F)PX@Vo^b4N1>qqshcU9lW>tZ9PEN5KkMnOOPA66vZ{pK`IXLSIcpmSI33% zdvT)9P{nHU=LgYqP6M{pA33l}fR7sK1=>xz-RBe_FXlbF#Q}6Qkm4IpEGq`H)MAak zpwJ^VJ+W72;$*#~ks-h_`Ngc9*`Bk@kA9I3t8Z@iT^iPoaLs8N<&zT1^mf1)@zKbe z^G|vP$Ts>;G6MZVhh}T`JG{Rv@Q6YS7B4P#wY_1N$_q8`0(~>ruPMYjfZC`^@wTn+ z_MhkEpbWcp4S5IDIx_hzX3LOocI4F`xS zL`1c=BAyh)kuw(-{pdh0>@m^)pR_5pyAFXQ>WD(cXz03TsaQ-T|zVN?;PNy z>L~E^L^`xzL~~1HdJAtI z6-|W0WJisLhj|a_FRrcV((Y^r-EY_3***v7{Rxa2`KnRrv8yOmxS=amVVlq5y~!n& zYnx#b`1?uM+`4P3@T>Q`6q!R4I1JTq@>BR6`xrH5ICQ}Q(+RIN10R^JYKBXT3X|dkU6O`XYZUl0A5jx!U>0O<&UuX5+v_wE|EDY0% zQ-ITr`cv>c-He+laNOy(B={tdw#u$J_HP+?Dbeo6^rXdlUB2qf-)8u~MQlgNK+zTY z!09a=z$w31+2%EhQIuh-_j|Wb2iEc@GLN7ph2}3v?5(%utCnwPNyBtI$lh+Hk5n2w z8N>S%vmZknzc#sj#HVEno%2~n3d4U&!^_HjIPHLO!rMa7dc41usEXUN$3mmUKALTv z*z}A%n%8(bU+uSkF!Ir2UG`|P%@U}&=!;7sHoEzZ`bxEt^mCKtO z4N~yFUe_Z0MkhUK_^)?WogM0n+vaO;ctIB2s=m4fPya{7tkj@6jZ;Vhc)H3KZsIDn zgWKD1pJ0J^a_gDcy{x{;$jqavs^@wFaZ96zP-n3x+>&(`rM`Ma5{>FZo}&+&zAd*f zUBQ3p^LKyw3o-b;A))#)A>!|$B!u*8Y9eRQ!*jq~YH#1AdNIBe9L!e98jysDT+j9u{tHj>uACywZ}~8Z1AzmA>ac>$iqz@WlAcRN>5ut&ic@)CUiM z{qJmL{?>Mn^6rkB&y)Lv0Jhtp{=0D*783(bx-j#M`-Is0!^S^%Za{KRS3ypJ^X1!Y zRjm{oQ?H;TVO1&SRVM@21j!B^~oVm)7&64 z*=}7z0%5~!-_rJhn)l;rCQv+RDPRYY12c#yfThK~MIl9BX7H_#>G~lo7YllG=T1uH z?N~}xIIqPn_TVuuc$*}uw7V3)-vq%FPF;Y@-~2?DoBed|SMzzKK!}}(Un}hG5(Ti% z-h!j6Z*&&n*JRPkmkrCj4?e@IQ;h2)`>%vR?~3Ol``cGrvuH@;-(D%cIC&A7W@M{v zWi4S-ml)2OfbWM@L6mF3Gw;imp^v8_MHp>-YIItN)(ZTgtGqY*9~LIHj}QXEj!N0jO*Q0p5xx(%b~k&K5c3%8E@@Xq1ZqJh)za0}*U z!qdj)&zjjY83ss59_E>)O3Vb5hO~L*JKAq4(Ovagdho{4$u#o2=$oPFU+g`)4x_!_ z340mHYg9~#UNF1^BqVnpvjn6FYA9YF8Bp|ycQqZQ%VX^Vh>qWSKLVp z-G3x2aQlr-zq2HGed4Nt=Au*M6|ny|U*;3HyKQG6+*&^u=W?#s)pKyT@R?UskNo&{ z{jZgi6|ss}gO%#HW9IGyHL4OjkNrKtrTgBE-GWlNnFLs))rM?NC>>XZV!zLVHFc`Y z<%zKh$pJjO8YGN?)zSc7{@xR%q>ae?eeFihA22ithF7QyVC@h{^pe zizUmW#;e-%k@Jcc*T6fD7i8bIV8_sml7eccTuMUKs;y|O3QN%F?sfC$ql-|5+rwCt z^*Lv9>tmd12*3c$e>hn$ZmCf*Km%Vnb8dl9O&3-5bn*g{xjZQ`NlSVUaev8Z8Xz(y z;bIsY>;b5aCY#=a*1;2PAA3|F>S7S}gaA0u*89%J>?Bs^>Gt0;p>J$u3IN=Mds927 z1`Mu+khoX0ny`YipFXra*x8mb*XB9;v3#>Nya0ZK#43H6+T#}hE03~%Q{G-YKZ;<( zsWGaM?))Z6=d$OIM>&-5u3uhJ|}D#RwPPbH$7g=Su8G^$>$!QQ_ahk6uC>V?dn(+tmM)>tl?z`H!#r-%etW4(FwG zX@zs121D(wfeOWp=mbM-uFKz>A)tmEB-N;sW##UlJc%XZQ$N@F(aR$M&!&V|5I>-z zm=MGqb$Mo?)0=-dKkNCe1Q=z04ZmH=`Yj{9cIvIyO(JB!&-y_M*VKz49!;`DfB?Nu z5%@lD+fNYTW}G-dhVPh^^dTk4IB5fw3_KJo^c_*XBs2J<)!hq#^xDVY-KYYM0N;bU zU6|gg&>~wtX}zxpmn5zfA*uYipLMVVxR6msP}NX-#4pI%oQZ_)V=YDxg|B(Y96eoK zOfCwD-u`2G5-aCD;A!*@z)uUO&>;<$AGvUkfIA)vMw5%W4-HFF2Nr+ZBS*K~0plEqR*6$8HZ zZ}0^*H$o){5wo?G8D^;JOo#rciW@(PE5ehwN~yGi$x|ucIt+%$sMiQn+1Q;PWd-ki zX*^OqiEU49nF|h0cfSU}Stvgk0ceeD`d-0V_`3iThs$qRT|IC0JIy}}AqBVG^Q~e@ z0CrpAso~M%Eic{=lKn}DuL7SFlIWW{Zk_~Jn@6or0r24=)K>7Cz*;GvBTIU7P4OhS zm<#d53NV_Er!qpiK|*~)R6&i5GbuoF@|wRphs1Ba@QCjktLl!$<`Gsk<>e88xL|~b z;Me!G(tXuMl(De#U>yKLL8en1_ zR4pCs&nZeg-nD|+HTDU9KQvBgEPrco%*VCbABT}e#aSjfy<*>#BmlFd2qVCy&`&)R zTZ;WxqSb)HeZjYzJ^=gK1R<-c>tsz+N7_knGGgWmOx7;r-=f9d9sPZ9ajo! z;xj+*U)GV5Rz!EOPZ($1aw>n31iIwIE{5hAJP506%O!yaV=DyM+#7+&l2a)B`wKr# zNYx(lJN!tL8k3?2*m`JqkeZTA;W1ip6WRbD7s4peMc+ETei0->dAFWoW^ zz?bgnw7rtco+lv>?n>nx<8c4FAy{Ug35@lQ(^7BHJqNI7yanRB`wpyT=d=sUwbEn_ zy|2`dLL*?4&MT5S_<3pO1p$_eG9Q+GXM_2#Z9^*Cj-)tA zRS0{l00CqM;-Rm&?|uQsstout82Xi;`QQCcf;R-nDB^WXlBmdlEoBJ)PwW-`Jll@k zi$FbNKn(vTPP_?DmxIvYgS{{A(s|VTANU`*w)+1+CMK}?ivXL9p9s*`7z2)fYdaMn zg4!>S^^xLA`y)n2+`om->s%A+f~OV2 z|M%xhn+*Eb|HE3_YAw?u!l3qe?r)9tGP>6LjnLeX$E2x9WQoz#F&idP@WIaJ-oD=Y z0I8AS!`71N81o@7+X?>{Hof6#Oz!_+6lGf6!dHWEk7mxPH&DHh=U2qn2KR2Y<3HG1 z-i5A7MgTk})b_V^YySP}s(oGc+%cQjx-&!9GC7YIP+QbQL|Kp@w=KYHVblDWOx*v# z&+@BxgoHj}KE_-#@WbgC_7P{iOq=(*tFG0`tkrJ%vGRr?^sc0?>>K@3HWXuL!70OfC+B8O)wt*EF}c~DGzr!ZCV^bgO7Gcv)}my z48wSKpbzRV!&YV$4Xu8+_(n2#=bB-?IkdIUH9}V6mEg5uk_o)!RebKh3ehIp|{Z zaXmamrpf%O+Aoe(VPcjSN)ca-V~}B_TB7oCI_D- zx>>jZc`+q|S*Ym^>r@14*t^v8c-4-xYqF1rBb^U3iKMKr9k2Fv=VnHbG04+?5mSpN zujwMcagpLTYLA|5B-nEn%(1{2Qdr!9lI& z{?ZFpF(mWEDlETix1cj%zqoivh*ltCKUTpD6CK0XXL<3waL6+fHw4BsY;gLZwUXI- zqf?6m{P5AFIuFm=dnW8&b}JkW92(h)0G-L;B#WwgfLFdJ)|N}>xUCi(k=eB;F4KT_ zl5jvxP6%Eb3fY*@!~A@{2K@^5ejW=n*{blDQ_(w$pp7l>O_KNAd}HXkXU{#r_Hq7= z1Q-uDxZ4TPFAyb==a5uAT8RowyBO)pd=i*63c93X%80Alp?CWl{D+4g)G0i0=;ny% z>G&&7inx?R=vG@3;nJ_6e277)wp`bvhu63vJ65LXT@_hrz;kspWTX{mq)G|YxVr`t z9&e=iJ#yz5KpXXQ_0^xu;OERasCS>^Wd#}^N4iHPgeE|e0wA@1&nr}PV zbd5ak=7IFawV#XKbuAC zL6<0IkjJVG3Hv?9m?#2qHp#pg(o?CIrzsfOuP=`RpB!MNXqC!uw+he~EgQ!}m&*iY z{sXfrKh_E|ed4_>H%&OCMx4)qs(Y!dsO5J<-P+VSz%b}KP> ziMXTDxXQM4)fU|Zj)n61^Cz*Vl>0hnPGY$wHb+7M_KI#>{|uCUXck0-tYjBiA)G9L%uZ>tyS|dD zQM6|dy^IxIp-VBO>x_Uv#J$Ju#mrtMaT!L@eb46<0G9NAc=P2$6-3$CBNeRfI+a2l zdx8tFF?5RPch=S`qWkKc5u{QY0RI82H`mSH`>3O;8tmdfkO9rwzJ>DD_fz6TSfcje z`q*x#U!u2}`<_2tYmNxEZ*NM`o;b}VPl5u!MJ!Q&x{L_G4<~P(O6W*Uu7s&r-qk3b zEW|vvb1H@YI&_tB9s;-|O<+uGBQm;317313TrPOMb_8Tc858?1+YH3&J~m@#1%HG% zI4%vNg48UP5$Eu7aX;IeLvjRqKIcr1=-&{f2VLD+zTDC8XikEtd$~gOlvrMagFdMu z*j)%pvbm#yLN^(7OY}hfbN9ZxM zd&@#EFqb!6JWoRaUN$sTc+8r@$pjf2>p>^6PxQMb!fqRYyPdd}*@$p5XUo=eH5(|9 z{zG9-)oZ?>QYhQ&>iOPxLBs<5CwcjbcukJba$sBDP2prfFNDcu(0fJ zR=>~hdF1hryLV>p%$alE^G>{fg#2s0U+Saxl4RwOCo`rh39rpQ)2cY;s}v1#>d^%Zs;=E_WYda!?&oyg zcrLxu`{8vV^q7cJG{e@C6L|GN{3%Jbv`@{F3CI2F-%JZdfma?Kz$)A(g^I=-rO{@H zeMn5m&t+hWHERU4Deu7`g~1ckmXefaNgc)jKnYdmoC8#^J7~xYeQGGNdzLV0j88Ts zyIk$I`i@|wkaKPUxMWDi>#H>pl>I&u3Oh3cd47qJ(|ziepv%z;Ic}(*uGLi@H8(t- zL(qRFMeMDRagmN=c(KDnC?qjkxU#ygxC?ZirUgB)D(zOT9sQa!eCS~hn^Fc8MRZh3 zb)csZb6x-}=o2&jD0=*q27H)UJi_8Sy6;OUPib~L0$>G3u7+Cc9gwO!ALp0oU%Y+! z-V}tU5-M`Tbp3}*V2ZIm;cD>h^u=|Z*Vm`&3`Ed(S?v-z>u@4IB4{joTR+x87|Ti)V-33XjTLa2El%4@y^Jxq zV4+gprd5VdtMYg)6qf#9RZb;ZdBt#GfhHvJLYxivQAM8d_h4lbZ$0PK_lk&JksK(ucqBy?b>(lykUGybyc`q!CK}+?-Vu?@zX^ z<1CxCI&(Bc`%0~)bdbG8l8>_~szdaqMC(&59lSPk5ZEkKVBL-saYudS7G4Vzgha}8 zM?UugSa5K#ocz~ZwM%+{#cBQ-o~>^v2nYfO18L}Z$sGTH|JEJQ0WOP=+`WHn$EYj} zl&rq52S>Dj!y!N@eZ8ouVq(*L3;GjQJFTmy0~#Izuu8&8Y9`wmc?c0!OlT?A+RuNN zf#={7#B*toZ70W1hXB%hRQz#}Mg=-_RMo3>(D8ggcM(XjA2tjuI$w802R9A#-W%IJ zYNCTY9<`bmW}(O!@StFq_i-1Psf2Y85f_5c0NQ1o{{qeV+Z5@(2*HHXN!0=FANF|6 zbj39zxj8h~pr0>(PGlzEF>L!rV+DcVR{Kuq01pVM2-cjZ`k9kMc&3I=CIC!O;Y08B zH~3$i>JIg$h^7B$c3aNRzelap3lF**=9T@^@?$GL?c>3op({7V4uDN#*3TnXzicOR zRh{DB?JVsVRc6l*!*$(Q+>`8;UO$IZLFiWYO7ktFESRKrr}Ap1-+q)NkUo8EDToZPaKUgL^YP?q&c`a;>MTWHYVw$JsJlvTQ>H zetPGKzj*DVh7gPh0H9SD3dq@}+Mi4b1`vO*5a(3A`i)DF=5T`J#F8JtMuU(3p}YuR z5RGiif73weFrEC(M(jx3b?AsdE-h`^7NQlB8$=2KxC3G#@c4BR_-*BhO7;yVoxUI= zgPgv}aw;yJsfD~aufVx?Ca=HWM55>?AQlKjSh&NoczgN47 zgNHFInvR5R-NwuDb}!8&Z=tFojlOOx=?tmsx`MowL#(d72`6?BvoCx@U3%g&-#}mN zK%fShTwO%aWht<+{n8C0Gxw&a@#Kp6(4Xp!5~_Op@#a#w20M8$QSgQ98QJZ&CSFZE z?{{3wvZe{NmOH%)Lhdc(lcBZ`&w2oCAsTFQIUdpL%GvwCas=^vcede%Imhl{e&vi0 z!d<5HPL&(WAj%hdaFVTKna>gr0uE78H!%d2Pzq*mVMllXgq|ih>!VRTL_l|Co<@&( zrEt6XTegBuV*TY+LI(?p&e4$8KIC))ZrAEU0%sGD!;hCIC9gsh_)NogCV3TIeLgAumI@zB#;f9nkqj9uWf@ zn=3-1fd#g5meo~%O+^NR>S{1Yg(Y)Bt&lyEr9v+)G0p9 z2OiFq8lQi=d-V@bs~o;qhdauD>CiT3|C@FEuy#^>P*!g4zAiNE@m!|#A~N*jhHEJ2 z%Uu%MTO8Y|*1H$Ig|oMG~IXf8_LV`*&2r@d7wcQcEOpLI!RLlGD$>hyFx?9N_LpuVozc%Q;*B(6IKxRJ$ihS<{6Gs^+De z`+|c#0{vSchO|+FoM~`XRC3_!uV;WV;bEo|+}gsbWfOoSr@fvBV0k47B(-3EuqW5c zrv8c$4MaP=#AO+6)<^f?t8l!)%jV5%Y1jnTN6_!Bi-gPL@P{l0JqSnx873Yo(h6XT zOVuW%A_``He}(|dLn~wk3|CRkSAD&;czF($;narsAFYoct*yzIz|y_LvVK_hA+hdX zxj?jC9{@9FRppiJwCg9365cEGc3G-tlV+=zloAA6usVev+KQ^~BA_>{X^OaEo>-{4 z4}yTP?tt}aM)xh+8mxT>MRq9>6W-r*k_*FA$yt+SEP&5|i4d$c1tCjfit4ok5t zN%0S3imCyA;(w_SbcWZKkbprK#<**GQ$xRAOmpkirUq0esZ-(#pT1LjI8;U&&GXnz z(lpyzc^)rtZQ32#<>*~G5~?Ah-9^Ec*u~mH9Qc7kF70!rWSYQ+34@Td1uW0evKrAC z0fpI;cwk}t#>JSa;c5?aaUF&i>Ze+lpwSh|%w%$dJOIu!Ufv0O@nUFt36A%;WQ*_! zyp;~~dhi*W@XZR3Ie(Rj=J}n_m-^S*NVCu1T(k?&j6ZC7gdg&Lz%IyCF{DVzN8t2Y z`f`!z`dc;ViaBAMk)bo@Y~oXGd)=MDJ@fN7{%4;f%TEYzvhC}_K?gBbA+Yyxqmqhj z_1q#7Y_C(XB77@sD*%`(72v-DM0Vuat+w2#2@rkLA0lI58OcbH(+-nPis>EiAOPTg zU4G56nA$M6ENXxr;;;1OD%Klpl?U)pU*=iLb!GrGEcT|}g-ky!Q#NIYXvWKXLN%)^ z7P} z8>syCH8BL`6iu*S|Il@)Q+xG6kaRUb3l9>RK42d55H~_fLFx#zxV`cw9 zi+HG@uUF{i5w8R%bmP9FDC3&bB$D86|7i-Q!C|((#FTbfI+OwVU*KZJ`?y=FJHQ1d z{VG?r|LH1^FBVA$z@d#XZ7o~XZa~+Yi=IPVoGl;8d}%~(MGu&ygZd8gA44y%Xm%1q zl5(!_MVuK|3)ZHH@V*rth6=j7h5m-vZ!He-@(J%up*9{sW?;@35?sh{@IHe);rpom~AIi#uDHv9lUO&JpOg9e@}?fYxqM zScyqw(p7x)FAgETKuC@i%~tt;o1?_>W|uQ}dOsClq>9#=5_rFo6nOci&sOJDHaJ|! z=y!>|hI4Uk0{%;KdY@{R^4NKilG42h%6pY=)@szhp`HM;m`i*^6%wloBlv4|sfM){-{zhGJQsgnmNbLSW(AuGdMMhTK=Y&kF#97e zyh=>dzk=He7T|bJF~3ZP5tqPPCqMiP`JA5MuN@*HFn$vAntogQhj?ulKL@(#!&?Kc zS*f(bu|hQ$?e(uEB*0iOQF1(q#~c2$AHKn(l~hN&#B@qUOzYM-JHuO1Fzu)Y-bE=m zQddrB*+)E3alBLIz6J;}fNrVH2%(4X%Hu*lSRz|_*LIpO0UGpWr6_v)1DU4ib?sPu zMQMLcK}x*CZ`{&+9Q;NHMAD?-z^>T$dv~b;>KV#uZmfEF{9CxF`4T{9Z+_ezH|y7r z#cLai{@or!JigEz$lM5UHCO@wm?I`jIG6-5)t`U2UFlMrpH=qX-RBkw1|dm(we6p> zjVo3vGScr3`x2WNl8_j4%=LJaO`3;h zJO9;S>5|-u)IPtWQZDh6pfgwe@<+YrZWe5aIqU!vuTV-;G=2tNr> z<{-?mc%bUoO`*Cu;KB6;FS9()IEo0nvGI$~h-IVxJ!#h|^oFSn?an?|nnM8iFBFNw zw0g=;NGVuv`MVe3$MGfo#)g2PnENvX|FH{_+y@bX0vSQK35jh9S6EcPla~46U6NoT zLlao`iJHnIVm#{%`-B%5^p>4fi#G0cKvm$lB+!u2P5wV$i6Qih2PW?K1oD1y)x5-i zIpy)Y?7mKAL($4z)8C^*SfAIwz{*(D{vm6 ztgplW$;s?kz*J4}3yB(Q8$pg#4z~CRH*fb>r0rzapOMh=Y-~56JIQS@IlP=uHwBw5 zws?8x`q-qvR{tDJOvG*^*@-teyxc8?34$(bo0wj zo#*H@n4tunD-@7PWFt1Dfc;Elv%^N?g4`(m&WB8?=1NmroyEyO`KEG2rhkhwD$Z~b z_`aLBS~XlM7~R#NI@>9tQU&k!4j=q#z!X{e+a_#KwAIyYT2fs9!bZVX@PgHPE8^Gy zgz4~jdGvrnVa~zbMM{Q|9##gtA!EbSk-fpDt%dLuQb}hL4979TOh; zpWq8aH=kseXJ$@L*v)P7UtQRY<}Iu~8yf4(VC13{;fB5W_F~bFeX|HMZu(wJf0X*r zU&yoy;A~tY@NBEMzzY0I1@Vo~929KwAi@}O3cjtJ601MDFOWqz=V{$rs$e#vDT|*D zi!XBgO#R;DsDB5Sa6MY1)ZQV{_;LR6Id3i(n9GaPUQVsjp&g5&VC7Zyw(sKz`?=_L z-9a8m<7;cnpYL*Mub1R4_^~9kmpV%t77M68BN`Dt8(q@@PUs6UGW|*3(ZArteMe}` zasnF?ldcg%B+|5j{pMp!o%8IT@Ws)Fx9LVFOY6YC&|L`c%G;|#xwgi+1cAoLSVy6h(P6N(@HnI2$0OxD5G-JSY5({t*5BWJw2}2PgN*e}zD8o=Rl-ss!D1vj zk(5jwdO4X!bcAg)hjQo6x6uc=kuRov^ev!oRq560r}Q3IZW`=t37ZoJc9H8!2D7I$ zO^N~o98X3H%D?8nYM1UPwBKFTrNCG@6?GeCC8>oT=v^|Ra8LYtT1;cw9N?A$yYi#{ zCr0K?c>IUy3ddu)NXjJWq4+CxcSu&I)($@&JzU6X)xJ$V>hH$|R;t97&S{-mju021 zpKTp{wMO*7(*H|9+u*=P2K(7j9_i@{zw17)exK=rQBBr;URwgM{OQ^EJ!y$4e8C)em?rN zBC|LMe9mnpjL#}LC-UhP6Vt(8uhBD1_cb0rch1-E5ek@rX$(+EneEM1hqI1Aw#-2LJ%`38RBTV&=!FS!q127j?G;UGh4uRdzl+lf(WQYlm~GO*!k; zVDYbfebF=8T1WgqDhqO2*&`FA-!I#^C&mE65C{f}R$w1nW8?Gt0=8ipU~TuwZ95n< zL6HA94oGpBFU_y&iz_zfH`DFC*!NMlU{=N?KM_m)jRWUZ>!E#oqHG&Y5AYrvq&`U+ z9oxnk0#Tz!o~L{;;tn?CuArRq2g^A>1^m~+wx2E^ZmuC5*H4LdG<;J?LFV%2fq3{1 z-ToHm+!OcKER?11!<)~z_t<;sV}NjP( z0YY)M49V9r^a;fzW>T*w(YR4BeCj5Jv{D;Q#$@N5Q}91U2=oIZs47MYJcqUA7V@tD zxK1@Ye1`91&?WZ99@Q!{=ZXH0;5H)e^D9Zlez05V<;f9P{H7b-;ot%k#h!e2YqZ8G z(^C~w%v&u0?5_TU<|(grXm(d%n)cpP1-NsE=qfhYx+cWN9!Mn7DcwtqR(yzUS5gU zi35l9BOthVS`#?vh#kA^#7hpGl(Km1$9&=N?2zooGa8ax4aZ`BRJ;tIKC zf{i7SAN?AuH|E>wTDh+#={?ZR8TquAk8phc;-|PNzYteew1^Ty4^_hzv9zn-)(4GctFCfg5? zR3O=6c@)5woG+dG7?zYq5q_b(#x!5%xM;^b8l2+_1s4UNQv&4nVy|ZJ;lL-Q?cqGu zr$1aze}zJs{yJEbinjd(h2^rhlM_gemPimXeol1AsT;zxF)w(VUoAL?RG#zAmbbpi zJ9GJHtt4%>5I``kCG?sF*gKK;+45EQ)0#*-*nD#e9f|DpZ#bb7BcQ3K&aUBhAILJv`JTv7TvbVWk%*6#`DaVdEE#2IAbb zfn{)j86vCX3TPcEx}m@FN!IK-6cZPR@sTqHF7b8`V~o7#qpX|4XM`lSqx37LQ?S6i zJ@tI+W(Q9@jez6dIG}&e4 zW1*)7*u#^^Lnk6{Av4`1vnJU9_RA{dn~^PmYxw2COr-i-bPdn6bpek5IJ&Cq;kDs| zzD8R{jIba$&*aa_Qu^xcq>qpLs$-g`N=Kmew?n)A78E*^@ItB75%uEmc<=^n9?lw%yi#W02E)jd@5Mdu;hh&GC;ovoJ&l#5dxq=``&(-o-~z9d31#DB34TSjwJk!SCo;PSU%B2 zm4Y^YBT(vyQT>2QM8nBt{-`;>$gE;8NV$7T2Iov~E^~q1!C>ES%7K6#R%VQ$;+LYf zm;DbPTznaO@&2LMm~ia{J7$ffHJf+lDe}ON+nzlJFt33h><0+h=RByt+(VRm*ZZs$ zn{plP=&yjSoIY#K)xf=h65n5|)y5zH*qtRY6NBlT2uF{+KDL^RUhj*LF?YXzZ-|=2 z&j@?cH&_5)a1`p%*UA^d`TDj`tvdC-Uc-nmmoX{ez&g(t=H&0XrQq`qn;A^N$Wsll zN0e#pU`nPwkvID`ML;VDD4}5kk^mzxao)NsX)u;)MYirYTr30yj5apbjGtq|E#o$< zicin*LbBUaZ+SEltClpdK<7(jY)5j-xG*lJu?4c+jUOiu4SGI-0gHJ62ww{jXd5sY zJ2r>QhZ%R>^a!*A`Y&%qXXs?cIYwoMNY*>48scX(HV#`#)(4@L8jg zdIhjMG&~fy;&{ufQ+-5kc^=3E&LX&60vq@H%q3cvhgS10w%-#TrjTpr%NhkH33722 z1^E5C>bLvVrwY_#DbOS-zBX~ka)*Rg3$IYkfCnp>Q*sTeH9FjI>|M14ZSJ~34))Ch zRuaSkZ*No*x5%KvNm!$}!Jk_#w-Vh}k<~Hxea?7M7emK3Pc2G@$zI2REI=2FY@Qpt zz0>r-7B%~$2T`*taE0~}3CgSAa-yF22^pgHxMy;{w73Zugk(pY@;SWy(qTfU161br z*@uN?>39VUn%#x``YD}G$w%8GSU+qnVzeV+Gk=u#KG;I`dOJK+frS?Zy zs@mxvAJ}IbREfQ=7q)dBV9>(E#3y>qCT18F$ewQ=E6u%>Gz~W zkgAcI5<&RAk_*y#a3mwxf42g(Cc>fR#Kq5T)+B-;&bsQwm@C!nM5e9bc*N^ryEiG; zw$X#c%+RkXo~SuYX^Dhl@D9Aqt?8uLF}?)-ZRK{%Dgm^@vAV9U(17;H3*2q0ITRMY z7}x-HD(A*KuxbeWcawuNTE*E(_8CiF&l%Xm5A2!9+iSGZW?*>?ISmfjE>ckl72#TQ z@oOz@eMxC&{ZDtfAq@H$q zNSB^}ThXI8*n(fbI|KOszpH>jmk-br;oc|x+P(h%HN{efUmP}Ctc?A+@`u}BS3KJy zPX;mnus7$>+rM3KFD#d`cuU?Badv1ujq^gd$^*Q&RfUg^Y^R-_T8qB;9gI_CiYo`7 zn~i|wW@i(`ijlr^I~@>1(>4gWeZ%)m+py=HPj0jI9k(DMNqt1%We~{x3e(umPCL2f zvjnh_aPu&(;{Llo)n^PKrfJuc^cKuQY{Sm@3ix!X29vM1*jH2k+gOA(l28w^B~NT* zhuc;Qh!bIY<9F;j$d4gl^CfJKvxu33vkSQjYzN{FPl*93G~ad| zoYt7Y`1Ja>q&=@BxPxGiAGG$I4}4|PV;zw(tO_!2CEOo^ui9$uXCs*{deL>O3S#>c*dT5=B6b6eOxJHEkyLGv?~f#-lq4xODU*L2*OERbji%$Vk*_#dpuXQ9kc z1{J%!M?JA>{)?g)4tj_k4|=}=1TjyEXq|DQ#nZo(!tTjT>BbDFh(I=y=MbTONeok| z6>H!yt#uowszi8?B7@){~6(69UlB8f3r-cyT}*P~rrV!PZKI(`qX1|5Qn}>{)&n;!mj6_I zWZbvLSEHXHpNK~*TaKkVs`<2}f3!2q2LVM=4g9MgJ85(Ww34|wFQ6|a+Ft|Wrl_4x z0?yBq7xa~m^|HemecilQ>NP*qp1RvPp$dVPX#HYRa)~Q7-Hq}|7_D4ql4yA+=#BoV zHk+*)c0i26HsSo~!E?S|I9g%~9qHMFsLJQ(DQf%rqOFXYl5!$C`lWq-JI9&oFR#?S zW&^N@sG&5UDC(N>;8FP$9pa3aPlU~s#v=tkJeTKb4uHNkXF;nxIpXIpNjV7~~vV zTBn?EeV+;i868Naa?Tqh;S8MrCHEV)Ip^mHbrfH=^fh|h;=nMn>4K*sWmqTi;5wK0Z?H<_HH!$t6-6s|O~@UVp7W|ab1`prx`nuJyoF@idlWK68>ZUQXmHiN>AO}=wwzM40hi&U)&{%~3N~SP zF#OL;Vd~#Ij?~94qXYCU#m=W_qlh+l(?XxgDHr>VzI^z-gKEcd%+#^@=@!zb=hBMl zuu9kp3lhb$I7<51p<=68rb}dG118pgDPRHqtonNm7)4$_jK;`li7V}_ofCRulL>ht z9#p+3M8!M^%n`Hi8krvx5UM7Sgw5E+^|?3POz6%BXPoIb8}wH3)JFi+@?!11U;=6{ zY~$Yetr@_U>X>4`AufN{Q-L=_Wjoyunyc+AU-N{7?le`&fNE;HGi)_v( zf7+xz%zLhww2ig0dT%Fv>d=WlQK+a;nS^dwVp=;ONuSs(;570ZyQg*n(`{0kge@p17_INEF2fMqU)@0}QS@A0R}h!imlLmRe2o z+x-hjzs%4W+l>gZ3H5-9jcjS7&Z~<27vO{o))m~-B$$Pzq$M$oAV|JJ<`N~pco6^T z8lXVeE=Xk2=~sqE<&*>AQ8vAW^{v`hYtHeVR)UQ#PS`nt_qH$Rgk;r>n?yfqOx4;L ze=wYDPzCrLcrCawgWvvCSJh7UuMeg1y(^Tl1fOXW`}6WkWei_!{Z<7G5;!=e+ESS^ z$N_PXEqMiO!X?>xzB6t8UEJh4t{VvIG$|e@F??^2uli>7mr+xZcCFo&C0!OkVdd_s z%kdr}Se`kCbwBQ8M(L}JhTUi2VgZ{u=Jmijr-ZrEr*%mQoTHdtNRz$m3OvrzVKG(p zyZ6wi@2(qjBh95kRK3;bX0JF$+AaYU4*Wy5%e`o4_UPyNL&#!%87dD9O!BL256Q(C zB1G5vlQK%M^t2CdyU7Rqb5q=?{`&0rpeD~C^=`ET@+s!Weq`{`j!Z%Q&5`&(Pp;Hl zU*(nIw@gaQHUs;9OYoV<*+XUZqDwc4q#MrIv}Y@N5D}nIU}+4+7;d@OzaO2#2D1-i;~WK;&;}5 ze-;f4sjQv5=piH2&&{juJnytRFO|0l29pP7np5=$y<8b-W7!=lIu=Yv*N)>mtbVlo z@kq?poBVb|qmKflGGkn>@6^>i2Eur0vOuyZtDpv{@yaCnO0tV05o?Kss0q)m}m;Y5@jme4gocuZKq-n5k>x5#>Ql zvKEy9FyPAPVprm7*@54J6gw(wIU9e?lt|Q#lo15RTbR#NVxl+h0D=C>QlRD+&Rz8z1j0X<+Pfb`< z_pPgE)T;OB*A%IOW6w{XUfHDS`ScDIG4m(wXKiuAvAfqtAG{l&)W}zqCYxf-wyn6gN|;Xn$xXsXVt*<_P@Hv!8p6_ju#oG0J0Rww zBbw&5*>kuy_i-+dS{Q|||7Peus?0E9w#rF`97|TMa|bI1-H|HDD^x{&cfRZ2EvXrP z&rmIN(M>bEKqh>s>^0-&)Zt?rNjc08zRHAm|H8!3e;M%@kc!zDl%M!;`8{G4HoB z5Rsr>{Vwm0E9hpPn?aUhFGlrhS9{6$HbQq2b9e|@7_^K;N^Jug#cl6nG3l+^OJ%y? z3!~qUSZflMs*nGHdu_5F9I?)!GWCs~J3FQ=@Dr!xF@GS{)M8eTRxKz{%l~jq%KG9b zOyy3^C(Uz()ew_5;z07M5?wBt?R{|%+C+E9se;)pL6TM82*Cp-cU{O*>*f)J`& zO1rpDimFCfbalr(+WO>ctQpIN04yde>PO+wOfG+d`hx`R&x3_gKZtst2QjCp#XV<} z&nDG0i!147){K7XbS3?>!Q}AJdCJg#9f6`(4Ws%Un@v1yz7xdEg4=R?V)KhMN{Q_t zCv2?pDj}Q0gje{EHhh69G3&))SMcHfT!}_W6iv)=;XM(s4>?LKUlFb882_6^=!_UOf&pi{p& z@_a>IL@Z<67OxpN#C7&VpT^G7&XA%Z^Ti*Qz3iXGi1C=En7b0Wv|cr$)z9AkZ1cVD z`}@snw`Yr#1^GpHqmb=w&T_Ui*yfT(%WC&yqAfCGL~N()T|RJ(uBEX$GvgGKLHFV6 znXFN7pKvy(0_&US&-TnJreHqBqISNz3A4T_PwjW^mLb0f`M7sIHE^C$GiW|KdSG;} zJ>f!vE5*ON2lEZ|?>lO!)a0nQ18Xs22}V`N1tmOPzdHr<>rd)s^=r6rYqlTBz(+Ss zik^x;s}=E0b3mhnC60=)3R0?BFA0@@1KJcQJAbbZD2-5#Eq!R77!Y%yze-H6kW=6? z_)7ubO4~8Aqz=DjqR6Qr;5F?@!&>%fNM%=rxxb)Ovu0ZOz zWE_73Nw6iWt(E7n!2kRZoUEJFwn$seb41InhM9b4>TR~#udzZFm~Qi|8D!{1-81;VIt$G6XzBNUki3LRVc++@p5 zUY@}%O7+jVV?}NO>l@zpO=zgyqBuCtm(4|>Rl7DYj-rN%FcF{h&u&|@*;F*>;t7%6 zkiFMJbyGBncGWgrkylf|QM9OgIW&kdz5rG?=_jT}Ya87MQk8pTyF7C361B9HtnbG2 zEzbs?&56t=4BAe;_E<|5#j4WyY76rbZ01^e*o~Ib_^H_556H4=*$s%!Rx6<7X2o6I zC>G<^5gowpCRuJu*d}6SP;XUZMx7xTbx)8_Bv;?#II3kO`Zn>zf$3Z^U8mCBkz=K# zE%rfSitM;6wT`ewmzcl zYyDu%ZfLgK2Kz#Pg2R>b7eD5@jb%w7mWOvtZvMH|eENA?_n5HQbF;Pj;`FOvmNTu! z#5hRefgO-AV*G6nD}T<{kW9kLA}!|1vt}O}J@D@@i*&b}Eb>)$EIStU-Mp0IKPHIS zd=Q+l6b|E9Qyz2}bDR(^Xq>=gtk#^iCV@Dx&KS1TD~dCBas_xjn1r?9Ht#^?#Ip>< z(ia*9cL!kh5)K}_e6zit#@A7{*-4$9&ccEt0 z(-!^J8*(v;Yu#4~H&DUU`KBZSioEff8L^ioH_PwO9E9E1Z+%Wuf{jg882WNP}cXS6wuV?dAd3zdc{YQ^Dgd28Qgd1Kq-=8WoY&`oWZ1d!1K`=7kW-J6;fedbn zT5zCQx3fbJ4pGZ}gVJeqwK-apM&{mbi*dcF`_DK)iQ=i6V~S@;#AnYkVjAvebQQcA zu@^EWQOMXq(LzztTAhH|3b{yoUtK+qof|2<#;of$b$j3*0b?u*L4*fZGZTd@Yjl(bqpUg*|L$iEYV4y29rQZ#J&yh_sc&l-K zI`p61cj7>Q8-wbTv?ZJCprcm(TNH$6+}{lF(?%x(S7dFbR4k3WF}gYp56>%6m4avX zx=D|dkrni6Fnh<}-t=)1=(u0Vmo|E|jhddCDFz4x{Flit=wY?7ow7dNtp2u+ha6qX zXb`Qs_1(FG8K?+=S5$k5E|cKw6Vc*x83nQA@f zmYeYGIdHF9t!rcIOua$C7$j10#w?fD|Ble+)z=+VUnV}yE~#RWLNSC(dyIN+EJ=cR$u=TkuU0*^Y^*ccia z0m6BpZjk*U$!_(=B~qzqo6c~s&mMw?XmZlrczCc+-K1llK>T9$Zgb=eGZesMMNxCh z=idlzbur72vtf!*Z>V_a1}YG{RlGBx;m-hu6ZiXsG_HLw?i6zHf^js9pheZDCULLC z+J`T))r-<>n$cJP_J-`8wey`+w@z?-H4qv#hN_T!?6C)Fz~9_hE4Zc^L2(C;FY#TC zWn7V>&`r}Er#=^$)zIK4zu%uhClA99gsCxSiE<)=_KEDf_5a#6bbfe2*&OjbR+IEK z7%)E$66!_mo9j8>ZZnessfTWJ^<3eV=zs^{vopO9WV5Gk8CIwzR@pE)%>z%}78F8f zFmy4SI3w;6cMaTgm{-}d1n(&&0rpYGJnc&E%Sr|(z<*?5kgZ#S2F3HMAl)Zwe^S7| z!~q=*ZLOR1On8dyTcUs`S;0d*7UWZHY;W$$Lfy|;@;X5Zl138 z3SAI?;C9VW%G{v1K!zy6*^>@*aG>)jZ^~gmR?tlbkW3r28*Hn1a>~G&RAd#hYVuj% zo9PWzWQ|oty(#DP-588i1~tJB%*JBR>{dh6x`+cG40(5bIcBZU>nPKz7s%XD-+JTxVdRl` zusmz5*4D|Bg5<{^!NGp0&G-q%fbttPVR(aGddvS5X}`C-}dZA(CbrN{Hm;MsZEg{;Sq&MU`bcIm*iB*uC52OR_NmUrH5Gt2m*i8@dgrMeZ9S!)^UD!O|p z;q~q50or+nM_ig?6M0{1q}O^s{1xIh@AID>9y<6nM7H$7uQcP6S1;F!yf>HIHF<@G z26@EoF6+rGXP=fO4%7SsdGoHYkv|fQEP~$je_dkP#ZT=&gKr|3EjOVnb^{->i5zxI z8Jx2OFH3>c#IcLxaQkO5e;Nsh+9UB~y<=vYsf8+_e$uWYG0a!snF{t%sG{*4dcg12Rl4FW%<1avSZfPM!nM(k&e@aDQe(X zHB~d*%sWzBwKXfMf&T(RqRAumtDh8}C%3S;T?5)s@j8Vcd1SUj!A0kWA@Ms~ni;jq zZ}knGIda80I6Y{_j>AEEIzpMvIwG%WMrzI_uWeRW0@^~G`14`Plt}jJ^3a3H*UIBk z7Jn0pTTG^3PU_e=NJ+^Tgz~2a-@7XO6@EDS-Qr~8B=cy681;51EH9RF{<==h~ zta$PMuf`1sbw7X8yxcYV48A`fhSr{4|e z)|Z^e3u*L5{}fCiL$eXi!VQ`e=~d!QssTtgsuWe@=G*eH#c1UFKaH*4 z>&1~IY?nkdihyvA!ByO7)lcLoOZKIaA^fja3zYubexNbs&sJv+kYXDW-GpSz{&r0k zi9+u`wNjN4bYF2|M*vVVm92|-w>;DD`~2UiFl>D}>ddLmpmQ?zyp%3kRib}j+57C`0-lJO)c1%^IIkZp0Wroh3Mei;0-clA-Heki0WRbBlhKYXB6d*#4T8ET zq3m9it~PtK^d8CE=eTx9ksi@dAC8K$a+4Um3IcIU&3qX^C?>@QHXK-j0wqtBYRV4P zA6+Pr3j!I=&86#1FIZg@vLybAr84U(lU`ektByc zeMdaAG~MTvur#&;+I(~=;r-gOXyiXb{qPOU)8qhBIG#i>Oe}G&*Wu!WoZ<hz)`=I@lre)o<0;3%O7Gwql+WqktcTjJt~mvR!u?&*KtNpyDb}aenAWPO4b> zC8R9c=%2XV4I(}t9u$>w{Xx}7_6)tp2jExb_23McP()@mS2)SPlFFt!^X8cblc&#D zh8V5@sm?^(f05Ni?tem~HuA3Q^e}cH(lVca@N4R92$9a!oTmoJSJMp_tfAv z9IqpY8~UTY{nT!rC0wSu;FM%%a;afq+yILsZ znXO#c|HORH#xwm;Cid6Z-3d{d*$~slX!6Y2~LLCy42BY=;uIO z72DrBAi0s}QoD&;;YzJQX4uRaF-#JU3E=JnbVouTLc2iPcK9K#yF}%|0w?7Qx+HG+Y}-t(Hp3s z-=+YE4o?la)z|m`b3SpmxLflo%TGTHQ8N?WfF2u9Qzb7aZzcmF3ouh+jBRX79Z%g? zZ^RLoz`h42SGo*$aGdG>oqx3DKRG~~(4raKD!M@FdcJ<<^SA#f z9j?V1_8ZJ#;M=(OkI>7UKMuqHB1f?=pZd3~tXe`kL@(nDLXY!+=SieH1}P=3W)sVi z&qwc;fu=IE0ch4L$k&tDDMh<+&zhb4*RAYYfQGU>6}TPyy+Gj|zYf%LXuvY=X@X0S z=*;kti}~u!IFUQ16kGt(8_-U*z7N<2k0H4a=4eG3zkN+09en>q$V=INo%+>Ljz+b6 zV&Gzsz`Z*^5QZ|Q`;ov*e8Ct;*#Ztdmdk(vFmb>Hl)d=;iYi-9r-Ax9=cBgAzi~c> ze$Bpzx!jD^k3t@SoJCMEXp><-a`Z($aev^eU;*W;cIZ zcYUPnuLZZ!PFvjfOK)~Y_kz-mz@1Y6SJRcpL%F^2cStEq8*)P=yFx08ESKCY32l-! zYem+vH)g7fE@d<(vK1OsqHJX>lSr13eaKMA&e+NRd*12u`+Ls3=Q+>$KI?hk^JOqD zM-qwFsz&>TSO^r{l4w2i!(XMoND<3fC**>N6$LIdEM@En-D)uY$6K&Z{8!T-W3l!^ z5R6*Sp>fP9JSb`N|CE3T`%nLyRhhOeN)EasyR_{Q;0bbVNb@rLT+4| zW231*MD-KL=nY*7^If;4&vlEaaRf;EAt^EqTI=)H<0?aqo^zuAx}y?)f_%Df} z)zjSR4LIi&^AgM#C#;i_L(C z1$7%Tu8A*BYP$BmI>zJ`VVjWOis&bGxe9!&-BGO9vYTbsrm+Vi4{xjnE*$GX9|DBT zVqR*}s#<-aR%Mh`*50N}tVy*GXTJM=cF$3`ci#7WGWDos%?U$4BbH9dT&sGFWZYDG zW}(}5Yqt4qAjg&Nws`gY(Ch|M4lDN{vuaEf61;JmjnFdao-H4@mVa(jSPk75 zy>Ejn&S!O7RUL;?9SEPw;W=;NWWi@JB%KoRNALf14CLS^SRFaZayvj`o|0=jsa1UG zxzYYVo2xh2Rdd|O1~(@HtNru^?s*@mDE!J>_iG3J>l)?HQ+9kb<4f*2>a=nU#-x@X zB*+Ls%Vu(vY0aNmH~)e)h_l}JvLBXt2@oa?2(v)DWv8h$`9_vgSH&sgQeV)PUjxKU z!9C`k2p;5#F@7~J`5Yy_LJ(d1`_$wf2n8xw&RuNm2)U4puAu%>{Q1XE>HaMDT%OV& zh$m|_!m}Y~xM?!gYtdChg})+_4~N$=KvIM(dP1Ta>0;pbigIZL<$>USP+H>btD<7N zTWyOB(03BzZv06M7D&B+=H2$oBHomPqk$$%IMy~Fu$eb2@B9z3>Fgq=@lD@50)`Lkii zteZ_F{}4A5=s&+p5H%UB9PuK-9$*z9;&Vk&y=`F4mvo*RO=Jk>eSVq!6{kclx}1jA zk&Lt3?X5OvS7EJwQ*Yd&yYI0S9l-H{qo+jfywl9iQcd`%7rM{w{lq%o^gqT(Q2t=V z9QHr{e(mhYE-_%GL{@JyMN~`WqtaX@%Vbe-X~zfE^W&dh+_<}5c?be*g8$gV5}#3q zdVmDz%&vyY5CG^^!(B~_qUs2N9fizL&3br$Ec{IP^uFrXb2pD07W>t<=*z)UF)MWu zlHrsi8~(NXS$0Z(Y1xWvu2wvCdnKdkpfrBM1rA39s%Ik(B z=?^f7(gWx0-)}3^UV0W>a3(;8QzkYz`2D-d9zazpyi6}f=uj6Zqo33A%D>N2B6n`4 zeZJ2yUA0F5unP$r!ZvcUaL~Lgx2(sQ&T{L5atiAupa{4Pi}St+P@|#QXUiO;^x_3; z6#j%yb}fFjE`_QEYn!Q-1aB~I8Mgd5#iU*7%9~>?4_V@}e|>ARl&*=dz1gZ7s(`5z zfxE>Y)Y}w}2>Yx)qvW#G!`pPs+3)XQTq{cYwfK`8MqoQWSnabI(x>LIc*&bQaQo2h%HtG71Ry5WV^>zf@JVmcKh1H7HMb?f0Z1rB}=Z;E^HX7b{-E8)DD>F+R+gmy5z z6NTeQ9tl3M{ekWh4APJ2nF#LTe==o1vr__69>TxzWzI9XW`CO(&oj{_r?{<4UBP}@ z3mXNNQ_Q<5Fp=$TL${lm?bUbctdbRYxO$+btNSwN?st8dJgOD*mkS?eJ9ugRcko*N zb$c7L5(+U4jjNJVE;coVerO!RauPPK!_~Hgt}KCMC-+UcQ65xe?b_x15?l@}6)XYa z9E|g_sbWN$~=vK!ACXN}yx5@a#6S)By})_&DCbLv{Ul zUcKe7_u3Z@V)wv*ZUs`0j6^QPHbQj@r(=eDK|RV!!43Tm*>;<9%fDFasd*amq;4NA zV6-h?lqZ?Z&J*mnt}2qq@wxY4>u5MYwrjwz?`VBF9x*ratw9 z(HK`UB;xl*o{S55`7dSzz-KFXft#o>$0mqUHNzIy%gXe*`Aa07X5tjGJpvD7w-ZvU zK|Y7uy#kZha1&?i*X<3qu?*$@kmAr{;bPb*-Md`Wlm?sC6u7h|b}TKJLG{L=L~k14 zv&!9KN5`$(6Y<{d+lPmBsu51e-wB&X{o5ZM8YuFJI=n*qbvZQ{w1l@HGP?7<+(XdJ zK$nil@Ny%_+M`ohaUx@08MhQA*MhwwxbiRVxY@Q$|`7P}|v} zhGeDYUtr)Pp&^Ryzdq@G%?WwSPCqv2O|IU9-q7OLHHVu1U z_eR4qdBweZd#hh~6ee02B%l8p>bftg?pI5;@@g7o3{?jCu822IJwn;MK1<8hbwm)U z1-O)BsGgH~_m!z@o6@b)66vU^nBEH-+(;%5D#FDCL;0x=hy)NjAnf+c*(#5Rhj5oO zO#YkGgA$mNgBo&4zdFuJCGd+a=vH47mb$eqiQnkFSES;TH>~o}V6f$F&xGsTcp?)# zLJC$1YAu;XZnm{7QX`94SEq<#D-)r}e5yRq*mM*z+zE5tzttfUc|J;fH9-&{^G?-0 zZ+w7k+j5G!d>0*pilck)Z!l_nrLkYxVOmicZSWKNizxcbYu^PPY1ya^gO>Pymt9E8 z>D=jUyS9kZFk6nIkbZFw*D&Z3`CJd`jh|8_G_lnVmd!_DOhbM*@rb=VB_FF&(~tKa z#i6DuD521GovWb@yg`7$nZGY(=Tv-96LNmDU4%X54w!%`-;jcB&>7+7eU2eC;FknV z3!fZ+JT7Oldatpz!Q|f@3<7$IsAIKf$W-kNMWg+wVbTe1&?w5QImXpWN_QW)H9zcge2}4L*3{7-@{pR)f zY6=x^4GA*taY8Xd|-%2CrZ#guDw%UE+E(_ZDT3Wa(BT2Cs z)gl)&rZ^#-Y`X_yk(!5Yn=Oqwd?T;!vsOC-ixs1uD>ioKdYyTCI#R_o5&&7Q4U?uw zG|(IjaD2;gRtXAFfIt}F46Kd2N|6tsiE7K*!w5=)weLWUPSWbrc$&=UG%9{tL8CPn zF#m2ewhiR~K$Nb6Trym!JF&t|1YgLRv+6wzFI{U>zb3kQeZpRzByS68P{AXE1Q0H- zwK!;0f{MSyd>(_z&T}2_J#77JToyNG8%nGr#^o> zgUDQVZ<$oKeCMwF!Q;uFJuzY!P z;mFRgC=8(qL@YN-%J;lJ#VE#Qw( z_OxW|%i~<{;Tm5W1$wg>q*TE2(A@KX>a0}XZoJ7(bwyGH(SbN6mj%4YVCWir=9dG( z0nkFSN|f0A$PpP5WTD$Sr8^9?p$=eHt9`7l92FZGzy88#>M>uaI~zgyVl*}}F(;?i z|A>2Hkh{r<7@`m7n@E$1k9p6@yp*+qn)Me9aj@Px<6;e!RSUyWC%2hxgpIyRIfR?= z_RUpqeZyY+45E>m0_-#S!75|nV&s63T1I1l9AtSCfC)JN3~uC-NjlV8Eg>QRo4m(4 z*-9(_(pWLtn>-1l$a`Q)CH?QEN5*V?=4EXjG(KL|(%-+gcsml&mhWBCabd={Jx26e zxASc;kWfYsU~R`)U%b$<|0`3ilHshcrVDA16vA0cnN(QU$u`J*GE9%oQS3@Zk9`(y)pKH z8kSZ50|1X~S3=_$*O+>Py}EG=HvUVvJDVxf?FD1=t9VK7yOI_9oQW3Bd_)}T4s=_$ z`=BzY8rKU8RVPTbT9Bf4_H0RUW60AVnNgff+-fw+4~8Johe2gxeM4`q&hpA?wl?%C zjykgNM}DXo|Muty%h^03Mb{fEIFLho69v|umiKo8i`DZNDEr*`Pa3y)kSCPtS-xc8pyQ9}^!S!A!K?bQ;c#I_&@7HP&7<{)fUq-?g$$KfvnS_t?w9PB zOgs$dItmDW;FLj@WcGt|S--xJ7n~=%dd$y7ZnzhW>aT_Am z!?C$+{Ml$A*<%TVtV5ZID5cPXy3?Ayo+OWOly#^3Cybs%b0fArc4Z|8-svwTjDEnG zF>}6}#4q0&kpY5pJNT0zHajb>BYsD9QFk|Ax0&K;P0v!ufVv5(E*c~zZYFV^yw=y5 zRt@LKKXw;$Bc|WM8zJ{@Skm6TSATu-S_E;fCMst9s=-wBrQO9mg4$)I4a?b8r{;C9 zgEZT+6FXT=rqoKHVGBps^s*qwMorcapI8+_Ht>k)G9!r|dZmIDn%?hcxR}Ba^&4F} z&{g@kYf3%i99{|wlPRJbqyKLi_tvNhXV;GS;}P zAuodN55%Hd!2#E+nAuOlEt0e<_{+K)qLh{nNt7A1r1rnL>rI!xS>{MI+9)bH5H~@L z&6CbM6ukLaTof^lK($fV0=d-{bAbzc=L|*MTO?wnvwo8zN+X5%sU^U3k%Sry_D1`?WVah{r25a*mr{$y3vNef-09g|{lNJgq*Erw ze@%2e8}7Hg6kV{+zZow=3NrqFprrUv{sHq+V|N^B%Cpry|0UhtH;|%fUV721K%P`g pj(ESI*KwlsL$DB6Ac9o<#_J+@7lzt@wjdE=pm+XEj*i{E{{iVRxnckS literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/network.png b/src/main/res/drawable-hdpi/network.png new file mode 100644 index 0000000000000000000000000000000000000000..aee79367116e48c1ee96581d9d1acfeb0beae40a GIT binary patch literal 6686 zcmeHLe@s(X6uyOyq6?r=V;B^jEFoq%oRqcN(xPFlY$?vgWdkIjpZFmFL^vgazyLT1$#0&4L%`?na}t3Y-Ir8twE%?b zlM?qHDs8e%Xl1j-tP*=($-MNotXKbyWq+R|dYCKM^p_c;e&9nw>l%Kdg$!xs&+ z5Y}~D5x5B$vVh=+EHMySuUyE5BxH_B7KG1Z8%LKHboAs0r3$C>u7>3>n#lkR#Q6cs z59%C&y(`+pw$iH9InEghR|~NR;M0ezr1eo3(Z}n9_GQvlMl2uv&9WGW^*3%%__wRz zOd172w5|sk;;ACn+%#8@*XZal3*I#ONCuJdR2klpk_Kva7Tjg2O5Jw%Za&FTV9+SI z`dxe|v&t<*u&u7r$ioAI&unqS2fKEog5t$x#REeT5lNX;62Fi)>kv`qsI69sRxZB~3`gCv~%vW^&ZqTAO&G)Kw4j zBgD6(XtkzzeP!`bQyX>uv~c;P{WoeG@97vsuBf=%ajV&+aTVht@uNp4ONzQvote0( z_9x}kXW=SH*HSSUs8qD{^+gYhIa*rP(;h|~lvA%@^q3|Ug{#*>kp)1oWCOGwV`MdX zcnTih0z}N~ixyIv10=^ofw(-oJ2id}e<&&3dh)aDWL;%v~PJr|(fNPC=$&VOfea!I6eK zUF?h!W;56D#fK&Rv=|v`BSC)CxLCNY3DGS9L)O>1yb#bntPod-u*;h%w6u=S+ZgdI znBqnzMrU8KF{4);Id|eRyD;^GQkQM@t zzl`b~a`NL229r+$?tXn^T6!2#;=b$ufdJ<2A(7rdzMgjVFL1v9PrdHkw2^baeVn}h QFmfa5-Q>iMeHo|!0n!!NLjV8( literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/photos.png b/src/main/res/drawable-hdpi/photos.png new file mode 100644 index 0000000000000000000000000000000000000000..404f5c27f12cec5885a8d2eceec21bbaeb8973f1 GIT binary patch literal 38228 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

N0{)8Lc{!Ni&7R%ekg0hx3f~T}oXbA-8TfH|X({ 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 UR!lJ(+Kmr#Gn_nlAj4g5Fedhg!7g-eo>-Zc{o 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 X^!EB?t#o*h1=#bK{we(1{=xqNUgO?) literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_archive.png b/src/main/res/drawable-hdpi/tv_ic_archive.png new file mode 100644 index 0000000000000000000000000000000000000000..d024c7e4f050ac79c3ca9d9ec73e2e1c53d0ba28 GIT binary patch literal 5228 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 BAcg<{ literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_document.png b/src/main/res/drawable-hdpi/tv_ic_document.png new file mode 100644 index 0000000000000000000000000000000000000000..6a0fd5d7506be46427b73d3866b9cfaa3c40f349 GIT binary patch literal 1461 zcmcJPZA?>F7{{M`chR#h-C8z-aTzOx8O3gpt;^=ips?DtU2T!rtP!FTQ(9{$RqxPj zb++9L0R>UPSw#dH7S^tXAwfV8+y{aVkN{dY%W!1!vIvYKNI=**U-rSz_~m#0`9J5L z^StET>?HLkT+S*E0B{v@X)*wXB?V|1UQ!QKqyvOl71D%lhl?Hs+}|en0N&nQQxXtZjSHIb^cG*Z+38wYp_} z>chLq%33H$ZVqT%T_>^vJvkdKrFEtl$&6RIRx{l*(l9M7(tyr^SS9Fk5Gx1WDa6V^ zcMh>q(0zm0M9>{WtORtYkys77^GK`$zXOSt;Lkx~IrvW@u?+m@kXQ;83CyYp=oQjj z9+*PyHyPXS(h?z65+Y2>hT9x|g#?V*$gBcmE;7r(coLbVU@Syt>^qSM`xlT$4#Rfj zk;3qI$RmN_Y$Q^_a4r&I|0EJ&zYvMA??k^v!IYXw=YdQ>hlPSHf)2x4NE@(@qzza{ z(RER9T*>T9fR#>k%?_~}_=mZaz>cmtAU~1WkqB}jy%+&M^6f`?5SrbBgN5idN|0h@ z7#rKo=K;#ILCa2IqyQ%Bt|A2UDccwZ7GhS@G{l7QDa!WQ>v357K47umZ}#Io_dE0P zmsgt}2nAp%E+uSE6I!%i*+Vw1E4OO*FBr-TPDFRQ^1}FQ@Ywb|(!cY5A{4**O5^xm z?w=Z$#?*1SVJvch13~u48$3Ac`vV^6 z%&5hjN+bKHcK=JL?Ni-uMP94`X%DxlTCQ-R!pu2glF#TT;CG2q_;j>fY%gLWoyLU@I9Q*sYyG-r!7Z)Zzir%iaw9I|jvO KwbYxq%kdwOWYLfS literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_folder.png b/src/main/res/drawable-hdpi/tv_ic_folder.png new file mode 100644 index 0000000000000000000000000000000000000000..93d43334fbd0067452ff3949700caed5fa049a80 GIT binary patch literal 1974 zcmeHITU3%+6h0tbG>vjB(h{@&G}B3wG9AD)GyBuDbh=E0K{?qP(1>Qh%cxXSR%X_3 zWm+O#Mq6bUM*|Hl8k-EqkRrQCcuAVbK$1ZVQJKHB=3y?q&&!;bv-dvVI{Tdc?e*=0 zt7D^_9NZiL08a3VW$^%DP!j{jk43jl+>6@)7?%Yv3;#0hj6p*^Ku>pX>NZYHSa!GG9X4ZB^%lv9D=2u2C z?B-P=g75@f-z=Z_4Y=4tE(z?N#vkf4NyV9pmJw>)iA{kvD@);>tYBf${GVEW&7=st zvZ&nf1kt-@93j%n4oAI>z~31ZKgMit@8cl;kR=J(YgeF>3_wMN!wcj)yn7xF?0(Ih zzOflBOpz>+v*06mN;9uYsLh9xy?P#AIh)J0Zn%ja@+tB2y6|aFnw56!edUIA zB#lAR-nYmQXV*xrvhRcazr z%rnTxRTq9Sd;pL|r?k^EtR-SK8HFdiVmS;H9^4e`K-&+3P59F_ju;RG15p4*@G}lL zdY&bOE?Y4$9||G67R*oOiM-otH2lK)_NVXf1XT;QTaZpD zJu{FCU{OV=bY5$ou1MA6pkI}$8E$2$f=Me9N~5Fmr^*63|^-0M|O5kt3KJk zuFWOm#$x$fcoehR z(4Q?0%UOp$p+;l$kc?>T!&JP@4~EBCg?FnUe}4wO%SJx-rdXJ}oG}MROLszRoiO7i zTg8(SYD3>`_YTZrkJ<$dg2d)6ooA$E`RkA2v@%wEAMJfk?PLKVbDF5Y2#5x~KL0tt zvPEHO{jRyo$GtR*5OBB+x?OKgldUX|>a_;1yj^l7q+vX}<5*1Rg%e3`f-GvbB{ySu zepmOc>i*mlkcYlMZ^QvKl9e@@2?y1?&t7Wv!h*H#JK9s%0#W%5C~6Go9XbHQ=+ZI& zX76uQ{_p&wxBruZuwAk5cAp(5D7|C^db+H{O1SQIT*(JQ)Qslxy?M4` z&U3Syj8lQxeBWz!fcirx%-#NL#hxwu>nC<4A$+u>7aQ>{Jgfhl^i*a-xMzfMsYkS_ ztlZGC!t^_;K_g7tc5!!^Vxe0^t7G(5GCPI^C&H$cD&BfzQ>-Zwp%)8ZlASAt7@pz? z(8rsbEy?ZhYis~AFMHtP1y-K|VN5L8cLlN-l@XwE1I*o}^As>Ep}HXRB-zZ=YccoE z)8MO)RC}c~XU7cOuv)YgWYOZdJ7_THOUsD9g>}DV>}1l0P)^IKFaP@h$q>gM*LDf&m{Do7G5M(h$!> olw*tGGJ75+ernR8F@`nGV@w?3bD5)d-bV-=8N2N2QpTP?07KU?uK)l5 literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_generic.png b/src/main/res/drawable-hdpi/tv_ic_generic.png new file mode 100644 index 0000000000000000000000000000000000000000..4e039adca7b5e24c1b60361bb42b74f2b1e920c8 GIT binary patch literal 1913 zcmeHI`#02S7=P#MTV|AdMJ~%xOnY1+n@c0CVVPFLhT^QILFj@rXqg(9?;t9qb;*#C zUm~j*X5|{+rG{}?)sb~?LMexu2t#w&*Kd2a|6tGl^ghq~InU>Lp3nQd=eh1pcUM-@ zQ33!c(>z>H06>5y0T8#tBQWNE7yydSG}rHa*(D1T?Nw*v^+lh$1RhH6Dc7z|)YsPL z3(R^$1<#HUZxnQEP(ReAF++{l4Tb}KQ(V<`PfzFmVAi0vxh*p4j?p!CXI+|_+1vTB znEo95*pGbc@Y26_Jo0Z8CS*?7&Mw){_9dBcrrv!{D4Jo<#j?yNk1vjnh^H1RRXZ{! zcmU!vPMhAqPd<@c59juoL_iH^DMbF=_X_Y$?4b(u~`J$PPEw{pv zXo}#~=6Ukx1$dGgC>Qk29g{Rws=lBDUeBQ_J{HY%LGb+B0oe*hmcJ%8E6dJL8YI;w zTP&@fEAm6zV&m#$tGoFb%KhI0)javxIjS_dt$3!PDFOY!p)*(C?!p=d=zBJK>uL$Z zFe9a@*i;yrtJqn9k+;V`QNa>pN?zJY@4<{NVEC%lDPk|1&J0Jf(=twOd$w$C`azWX z6}qID1X`pgbG35izdQ-Qnb&$=uaSY^7OnAdb?p2Z4sGMRsn~`PGV39V@$D%Aa$7M! zD-(}C*+5)CtGPh1!>bUZqDi-A4(@YIrZ9}#_P}Td#%8~zN`jO4uDg71SHMv+Q?C(r zyZ=x6o*ZGbhAh#h{rEXD>nVzfOZaA!w8ay-PFXS@AK$G3Ob%x$#2>Oi8iYEEt!``@ zg3rLPk0xtDxvxUU?WF0^k{A?wZ0Ra_ll6);uEkv-0ZxdvVyP?B$IFOG7HlIhwZ4g5 zPo<)aEIv*rf)>lUFNj5xrfjiW=%)!*UX0S25okh}goxmiIwEe%-gZrCWT&nMSQ#}g z)nsL|6d*vM1hf|Vp^O|pUZ4TOg4pqrD3P1wr2I_UT@S#^HjhUdg2~ca6bsl}jea;Q z*Gy#?Z$QfVofNK?b&{Syv(Xvw?!7)HVLZx&&us|$hHg{1cRmeEeGvS?!v$32V8u4A zs?n=4P(-m2-8keRY>;JL^>f-wbv2-p!pGxKEVcH{D#`aF3avw}ZgwlRuSfY9V6oEq zcrPs}FPg$_p4|d31(5}nWcB-(gS~;BKNT%asO&EP&Bzew{a(+oOI&(f)$`la@WkXlD$R*5bSi^;jH5xe+BnP`AM>l)w^&{L->`EtHQn0_B zK4NDGZdFX!yNmoJ0hHmh&j1KW>23cL7!>RUc~e zV)oG`x$q$GJY8G7g!9kKPzt-Z2h~Oy-Zjit(O>n>jczc2}-WV1*^o90K_)%dE{S~K6_qAe^u4Oc3lxzSy#U6?6<1EQ1X AUjP6A literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_images.png b/src/main/res/drawable-hdpi/tv_ic_images.png new file mode 100644 index 0000000000000000000000000000000000000000..abc689c370e39586574a3e6a4b5e35ed989f5afd GIT binary patch literal 8522 zcmai4hgTEL*WLs|2_Q)Cpn`M=MF_ov^j<=!BE3tO7C=C{N)aj2RFEzp9SI=tNZi-kCf1KF_n8Y;34a1!si=06?X4SHlzlAmCpJKt=-o z*gmLq1psb9M?=*-tZ=s|B8|f;92;Ye7P!m#NmVDn1H+N6rUHqB-N^e`Gr8ng-pupu zl8lDjUBY4*O#{`Wf$(;z7VXpUbg6MaAyMnR-T7W@5Sg1rLgZ_GN3;n;v3NhhDnG|M zIcGt6^hI}OcV>I&C|-7b^en4}W^WBY^iy#;a*vu=mFEAu_LH^e7*%L&xzi?VvA?jg zSQczCBnCPY3nPTl!meD5`X120xTl^W4EZocSo#L0u-gc6g0eh$BeIr!T40}2MSKLv+R0XqDGB!+Nf@vN&4R`;qQpw%w$A7$ zU1>!yn!!yvOp~!+BDo`rHR}-jPbBT&rU$=rG>OYXv71%d)AMZ}_~|0!P3o^g7=&Rf z#0n5Ajq!A&LPGJ-bRKwy<*ic0V}xO7$=9EfmCN5eLU;x70R$GM`y^c!fK#C_=~r^w z5qGXU21m|3qElXzeYW`TQ9;MxokKDLV^VzYo?Y zyY|C}y@#Gkf=kmPqM_Vn0?4sOGWo|Q4Aqxr_d>Ee_X8;;km=tf76ZOn=+J-hL_dxW zD+P`VVi-?)AH?$J�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 KYSgJY#{D1n2)(fY literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_presentation.png b/src/main/res/drawable-hdpi/tv_ic_presentation.png new file mode 100644 index 0000000000000000000000000000000000000000..e86c229c3c2422468bdb07a5d37755d2e903d4ec GIT binary patch literal 5033 zcmbVQXH*nTm+onpAxlmY29TUlBqzzd2!g)kAW3}5Ns zkgOo15=Js242T2)aog{F=j@){v*+yDUtL}IR`FBi6$e=ON@y0yWFlghJ7sEkp|LVYr zk*Sm^_00!$Fw;^;DuF=VQoyUxt>=(+mO(B`0uWk~JQkLZxBC8dSZh&BF53dQRam#? zZu>qef9}CA`9P+^Z5uTjELeWdJ1<(@x_2Wl`r)ztd&3>x^ZK+Hj@rHH@Clxhy3y^< z?Q6{a7zxHPsp4{g+Oeo$k+{*EHD?$7flpn7!%R&F!IWap8pA@&Vi>i`i$-Oz%L;Iyh{1q2dw~hFa3y{B}h^S0CV4JKcCx&}V;H>+Wyk@n}SA#b-l>odQW258d zjH9}HVwi|4&u$65;`9UH-#XxW+iu7H0y^-17E^3bnvK~I7Iok1j20N(Zr<(=O4gg^ zXuiA(LRR}G_3h3LsnfsHp@P6JrQfTjDPq zlV(~yTie^N8Lw5Aa@gXTXtofQG z=8r4;uyvSY+=b(?o;TTCll$7=YV(9lt)n>IG;19$Ic;npc)=Ww#z5< zKcF~54xYF8aN3hFTikT?3B*wkcP?rKwhrVd-#3X5TX;1E$q?w6v)M`OigH6mE(}su=2-tqzxGv6u80aL5RzL*qRfB!)iSpW(xqMd+88Gd~J`y%PGHf@YpA zke&??YU&Ui8B#!Ej7vnZ4YWXZgqakeztCcU5MX%*4>j(ZL^7okJwoL)m{Z3RtOHW@ zFFOI*DH4Fb&=Y_ub^1Q2BkXKXCBSk4c76?ikog*rqJJ44F6T!r#G>FtFYG*gEQ{en zVh(ZN)>T(EHN$bu>2>r&T&-h{HRgzLmph9AA^lfe!#n2EXDP!w)hkpLBms{?T|#qI z<3P^pQFQOD#7&++WLWM@IAR)cjDyyskr@54St)Xyx-&>9SXs7q;W(5bP2;R}SM5=7 z7Fuc2kqi;22+@(DS!e|EgXsBcl}k+(vt6a|OP8}nVi`^(w~wKqZ}~F~)c_OPUX|s# z$6%t6>FJrnP*NpC*Zd>=sW|XD`CT|dsd6BvUC5Mx?BZ$TQCVy^=7AIcC@?K_C?!qJ zkKoYc^m3sEb{hQoE1%5Qw~NJ-4ux-$~pMXiB|;)@_`=EbxKnXbI+>Z02yoB1nZ z7J?4Y5Ct~XB#(xlfX>vf#6MRsK=!vG^1X-YQWd@#HPqO{1Jyp>ly29eWzhg77q z^UF`^Bs26oY6o&2x@=I6M#{bwfy?0>>_G=9fc6SC026mZGKS1FFj)oacqF7r+fiy! zbuDyCm%|nq;Zfka0OrQ9;20?F!f8G19$VwRe)C3R#$J`^Iy%aoSQh3UO?Y+`-msaFV+L;u>c;G9WjMLEq*(1wd9~xSe=aJ@_1kqWU)T}-&Hw7ulXYv6 ztu=eepHw~DUrIE8-br&O_wr87Ku)5f92rT->d!FJJA?TtWa`YfA5Sp($G5{Ie`Q&? zo6nPxBA|aiySQpmefTX@)|;<2zf=bZQbBQK&^Li+Np&@o6C3b2QZ>V=C5)|&Sq1dn zvmWq&Doa=P-Ra&Xb~}8@%Cb$5!Mn&Q z?;*QDb=55Y$7V5{nYp3t5|FZT2Jc^bSlzVw*tvYx%%0mKTZ9wW2n{6i0+v>BLZS24L^A_jRFK%x)C53tBNsB_xk@2@Psg05G5aIpC`CjL;m z4=8k5EFj5(Azsb|%99J*g*@dvRRQkBe|c+T{X0@*PS2Xm-c`Q{ zF~{lB+=G&haIHRx{Hf8JnovIeZ%=ThN>PX#1d~Q*1;m+cQj~WXKAtm3uqI+Wk zew|i7D7UE~bbD!X@hw_Ln@nWweC7n+T#!m<;C+w}H`OVnPnmMxrtits+%dr*YE|Hq zH{JGAMY&@CvaFMLmh1dToeOiR4a)z#>#6JpJ#yKAkwVIt&M&JfM(Uj=EKEq9kj%%C zAD7%p>1vPy#gBo1MiIWHI@d-kfqQ1})G#ZTDUPx488lD9uOP+@L3q7fogvnKa;Ljv zV&nGToVg6Qzt60VUHl6%UeCv%)jE1g^epmnjww`rW?TI0$gt-Hp3$NAqeoq!3oXXe z&*v+4cC%^ZzJT)JqaW{i-^^AiUu-V+}TI(>XH{Y;Y$VD=7<@Avv~ z)G{awMs>$YS@bJL_D_3FzWFEj;f@m~2F}Fp^*-6>E&JM@Pi{1UZaiyFXGE2v>jP<# zECOj&by*e_= z&ke6<2tI;vQ*BsM4qCb`*EVb%F5?4v$AAt*rnDwsY1@vp86$bti=q-fwA0 zYV*ssrUQs^!y%{}%4N_V%lHSt$z-}vv|hRj(2t!DWZX7*ar62)$KBugPx!AVZHZ&g z0C5~S^g{+DSyHi?b z7RnBwrb78$->_@t2_w*N6k+tGxJg39D_NcTWJQJ+P){G5){=EU_cML#_pUALZ!1y{ zPJ+M*m+HD=m5+JM8HUpq-J*gEL`~s%4tGFT>X`bH`;!|JThE$sj|R~ zfK#sNtzO>}DD)#_8<`dX+rGF*G0r`(|7Vs{gmx3Y|O8%nF>srG8R;&zbe z%h25pE*AYlQ*G&8i)qMSSyso4jpK8@>u5iRZoO@ChYFfHKN{1)e2D5$4ePuctLlxQ zgb&0`$Oc<%A^*a#sNgg1ZR~isosuqs^uXu)^{kA2@_{2SH>S8E8pBb4=#W0D$lw0_ zloNxlcsnoY|N6It%#XWgr2t(&D(_+j5*`w)?)%|->-y0Dz%ylQ~Cwb;cz1@9i=;)-O4Z6uRfyDUa zT$$MDu|kW@T3W3r95fyMW@~L<mj_iI#J4e?Q$9@EBbwna%H$cx&(Fn@7H`M9Bx&IkfLTk{mp*zE8s zY2ajET&4^A?`}#$d;lvOu%P#gzLWFt8Yv6A=;f?Mt8u0T9mdsm;TPKnFcQ7Z|802w z2&u_a1v9FnQZF$q&rB1b0n@V{U<{OVKX9`J^JS-<0&ngs_|{je?Yn=j?dDToBnvM5 zb^Q+uo2$>#OmuR!ak}w>zT`~@-Rirpl2@QBw|LV*kZwRnn3U3ZCu{MO%_X)u`9I{$ z10tjQM?c+3a?Tle$}Oz57R^cB&GerP^bf6tDd~dVEvYTtKS3$Vp{f5nP5rmi{}}au z>7e^LssjU|)D-&nKu?JD78OAoI>>T=@yq_1!ojcj5>l%?@y?uyj*TRC_p(Q5to!ihVV-Bbjbtt`ETzvR50|niV@w~e?l`iWm^P&Pq=qgEaK9OnV zA_}Je?Ef27JNDK<%4^dNK;P|;1}KCjfE!G@|A>NxDm%C#BmQrBP#{aiky^+B!m2^$ zPru|J4|oU>4no*`2r__wQNk~W*M~I)bvJ5;!k$`~rC5YCDr9ON2fK)dKt|XAn}_2K zz4{#i{1GfF)*gfDdvbqfAMO6j!mj7H)8cLjly-c;g0nq7d2?jxF}Uy*%}-?xjsa8{ zK0Rri@pXfI4NzmDlNWS{Tc|Fc!$^`h=?uxoR=i}_R*Bh*sG^fCLI;7{lG~t8QNcBc z4aceiI;OU;>?1lSfC}S{233>=T_T;W@|Q=j=54YdqK3f$3QwF1k?yjhlM6F<85V_@ z6?iG6R1G2p_P}lFUTh3xqD%5KyKkWXK?xrU806Qx zxYJIxz1(fFD5Ts6lGj7gXIv=mo%Ln&N|{d7snh);v@L|;@ht%w%aw^L=`9>#mB`CN zddwaMwG2w52sw)Rp!f_{TbnO9^)s%I%DJht8K}<#M^9lR#Z3<+&q`<(A~kxgelK zR9UWW7TO#y0@nIrbLS#2+?CmadV&i|sI9VSuofSGeDta9<=5UmZbhOvbIK8eGmnjmS-=hVsYK#m z0k|fu#`0dn^}96`~`8n2-O~xSWCCVyX`2$CJKW$6r+}5wvHg-Rtd> zWD5)O5+ANu9$m-=T`0YsQVM2wQs@#De0f^WIi97zVb|Jp#g7mh6206`dg* zBs>$qh|a)02>`shp<5E(pDW8&TPIq_ouX%}G7LDJ+_&z4?_E?lX{Ct4tE;n@{Jev; zyS)az9WULH8t4!Q>}z3&a^}i1tbAMZuh8JrH|n~?{Rc?s2dV%7 literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-hdpi/tv_ic_spreadsheet.png b/src/main/res/drawable-hdpi/tv_ic_spreadsheet.png new file mode 100644 index 0000000000000000000000000000000000000000..ce476b0599486f8f7509b7f31b37af812176bdbb GIT binary patch literal 2422 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�q6N->@QOnV;!zxLU&w;lepPdaH4f_WDQ} z3Abv^+(y);9i}5&JX_}LgngvC_dT+lp#eKJoo6~<%^7OMmU($`@WIj1yG}@-XYO{R zKrz(a;WNdQ=>1#YIzzE^OdOnlr@6apodZ?2@yln`$vJ+ zs_BUw3fnb)4r_~Wzsb4cP|oYwoO!%uZj2N^xY~D?wPh_gniOw5Hy_b44vQGIV=p@G za8!=u8tMEp^Tg#!MOQROMQtgGSAlslvH9JmePisU;Li<4wu3{(E%|_O=4yrL&}<@U z@0w1`_n{$Wwqn;oIO8Wrn;lx5@HxeHl*JwwF98#g;=6$n#xOrPFN&|^)w3w7VXZC9 ztZJ>nC|?qyoslQlmj2_pu%cAq##>D?;K&r(F77_2TdYIK@Jd&Bpoob`RirOt?h?i8 z&NON%{obe%@2=cOLPl9g@U>A^r2%!+oXX4bAO((HXtHU}{N$SY^z~SvpU8T;_HqteuSvKWU&;1hF9Q4MS-if)Tz>72847fLOmU7aM zr-=v;R~&^VTZQyD(|$f`<7;cyPItX-{BQ;2DqUrMchW}Sy^HC!o%Vqzi!;Jy441-6 zmGWQwW;QLzXHc!E#LtKTW@q&}N)BVhD}DQd^vWHi=*T$#k{d2&|JFt29Hhu? zTD^MRd3m}p0!#iUT!{+3QZaIU(@_qO!Dt$cXkFx+_0$<};AcHld17*J<<^8zHRS-x zgc9PUCIcJEGflE!vro&c1#jT>Bz9Q=thKYj9DzpmN2Thg&8}m_NtK2`*K5!p^BGgEnbY8F5RPBzerOebSzW%KVzKqn+K+n)4N_ ziUl)S(4{T^2Uog19q&8P)ZS3$LOn zORX=JVi?8Yr?)9jhMzjt2 z$(}Cq)yLxum|Vo#&25AseyqPVM;!FE;M(-V6|}knbgjP8!nHB9lR}d-h5YWlFJ>wr zAUg8a+$vq8_m-Bu$FPXLAL;wPHQdb5EJr%<)0VTTu^j>Tu?2PUE>n5t4eiGJ9x-QJ z{9t#_ONzS=tU(f4YAs6IU+g}ZHTdVc&osv<--{&`6_h!556Dja(s*Rt92C^1EN|wd z9}yV3;^=Md6%$;rdb`w_+MFuMA}phR+zY{?x`8vEQWC%P5KDD-^%w`!=)VM%KWO2< z`K6r)oHEZ7QkuEfrQ2(`SaMV2;r8Yh9hC5DJbZUB@qmqBA^mpYO6b!68vK%@(XBi3 z-e9}dnelmxi2nM_XW`YnQwNOW8R&WOjp8IXPA{7-XG7}y1Iwm(oYLHQ@Ml#?^|IuW zr&K?YzXqe3SuvCGw@#l7Au!(hxCf%#3#0#NF+fVM9+$AR%FT=~$=antn7#34{Y#yf zTMyb4?%55-8ID|=1#no8E9Vl$)PA3J9cp0k%g!Z=nf+>Pc#X(tD^mKKYHgrkR2ucx zqI(x#XZ?*g%k)#Ur{DM+6O>AvEd<;ydc&6-y$1;hF)_Sl%DpC~dVR*sonGr-)NS*n zITlOzz;OJnxwEw--2cG_5k}vr8RLW_%bBSrmvFWL9mUXMI(0T!ei)me=|12kR}P#b zlTdT1PT%2QM{`B^LhV#fLpVH)(&bvWP{}O`+7@A^L6CCWdSc#)z@q}(&IL_{P9zdA zmYG4K5qFrwy}{?q!^f(2H1oGpA-Ey3PY0urHaR0lc}bS*_{CfBYNV CX+Z}7 literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-nodpi/ic_app_logo_shadowless.png b/src/main/res/drawable-nodpi/ic_app_logo_shadowless.png new file mode 100644 index 0000000000000000000000000000000000000000..1fa5ca1477710fd95a3e12468d42677232742a34 GIT binary patch literal 38658 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 qr7K literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xhdpi/tv_banner.png b/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..f4298679b8b848024cc2990e785ee51ec7fa08bc GIT binary patch literal 18592 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»&r;%@M+O@6tiRJ9sCUYnv%Q8@Rmc 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 Xwg%>fOn{9vIV3~CyWt)>5UGCw_f6Cl literal 0 HcmV?d00001 diff --git a/src/main/res/drawable/ic_add.xml b/src/main/res/drawable/ic_add.xml new file mode 100644 index 000000000..cd5c04578 --- /dev/null +++ b/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_brightness.xml b/src/main/res/drawable/ic_brightness.xml new file mode 100644 index 000000000..e104f0faa --- /dev/null +++ b/src/main/res/drawable/ic_brightness.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/main/res/drawable/ic_camera.xml b/src/main/res/drawable/ic_camera.xml new file mode 100644 index 000000000..a1a55a589 --- /dev/null +++ b/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/main/res/drawable/ic_cloud_upload.xml b/src/main/res/drawable/ic_cloud_upload.xml new file mode 100644 index 000000000..8df209ae1 --- /dev/null +++ b/src/main/res/drawable/ic_cloud_upload.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_delete.xml b/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..acc5ba61b --- /dev/null +++ b/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/main/res/drawable/ic_volume_up.xml b/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 000000000..23fe61f5f --- /dev/null +++ b/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/main/res/drawable/percentage_bar.xml b/src/main/res/drawable/percentage_bar.xml new file mode 100644 index 000000000..2b45fd9f1 --- /dev/null +++ b/src/main/res/drawable/percentage_bar.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/res/drawable/splash_page.xml b/src/main/res/drawable/splash_page.xml new file mode 100644 index 000000000..8c76f09e7 --- /dev/null +++ b/src/main/res/drawable/splash_page.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/main/res/layout/activity_intro.xml b/src/main/res/layout/activity_intro.xml new file mode 100644 index 000000000..c58ae69d0 --- /dev/null +++ b/src/main/res/layout/activity_intro.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/main/res/layout/activity_main_tv.xml b/src/main/res/layout/activity_main_tv.xml new file mode 100644 index 000000000..6190508f0 --- /dev/null +++ b/src/main/res/layout/activity_main_tv.xml @@ -0,0 +1,5 @@ + + diff --git a/src/main/res/layout/activity_native_video.xml b/src/main/res/layout/activity_native_video.xml new file mode 100644 index 000000000..e423cf838 --- /dev/null +++ b/src/main/res/layout/activity_native_video.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/activity_navigation.xml b/src/main/res/layout/activity_navigation.xml index 5b0b9d05e..3230ab763 100644 --- a/src/main/res/layout/activity_navigation.xml +++ b/src/main/res/layout/activity_navigation.xml @@ -28,6 +28,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + - \ No newline at end of file + diff --git a/src/main/res/layout/activity_server_file_audio.xml b/src/main/res/layout/activity_server_file_audio.xml index b17c06559..954ae1db1 100644 --- a/src/main/res/layout/activity_server_file_audio.xml +++ b/src/main/res/layout/activity_server_file_audio.xml @@ -40,6 +40,7 @@ android:id="@+id/image_album_art" android:layout_width="match_parent" android:layout_height="match_parent" + android:src="@drawable/default_audiotrack" android:scaleType="centerCrop" /> + diff --git a/src/main/res/layout/activity_server_file_video.xml b/src/main/res/layout/activity_server_file_video.xml index d6986f5fc..7bceb30e6 100644 --- a/src/main/res/layout/activity_server_file_video.xml +++ b/src/main/res/layout/activity_server_file_video.xml @@ -17,37 +17,45 @@ ~ along with Amahi. If not, see . --> - - - + android:layout_height="match_parent"> + android:layout_height="match_parent" + android:background="@android:color/background_dark" + android:keepScreenOn="true" + android:visibility="invisible"> - + android:foregroundGravity="clip_horizontal|clip_vertical"> + + + + + + + + - \ No newline at end of file + + diff --git a/src/main/res/layout/activity_server_file_web.xml b/src/main/res/layout/activity_server_file_web.xml index db7151c2a..fd094679b 100644 --- a/src/main/res/layout/activity_server_file_web.xml +++ b/src/main/res/layout/activity_server_file_web.xml @@ -22,5 +22,4 @@ android:layout_width="fill_parent" android:layout_height="fill_parent" android:inAnimation="@android:anim/fade_in" - android:outAnimation="@android:anim/fade_out"> - \ No newline at end of file + android:outAnimation="@android:anim/fade_out"> diff --git a/src/main/res/layout/activity_server_files.xml b/src/main/res/layout/activity_server_files.xml index d5b7fe045..19ad017f8 100644 --- a/src/main/res/layout/activity_server_files.xml +++ b/src/main/res/layout/activity_server_files.xml @@ -18,8 +18,37 @@ ~ You should have received a copy of the GNU General Public License ~ along with Amahi. If not, see . --> - - \ No newline at end of file + android:layout_height="match_parent"> + + + + + + + + + + + diff --git a/src/main/res/layout/activity_settings.xml b/src/main/res/layout/activity_settings.xml index a25ccc2e6..9c37b4658 100644 --- a/src/main/res/layout/activity_settings.xml +++ b/src/main/res/layout/activity_settings.xml @@ -1,9 +1,8 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - \ No newline at end of file + diff --git a/src/main/res/layout/activity_tv_audio_playback.xml b/src/main/res/layout/activity_tv_audio_playback.xml new file mode 100644 index 000000000..9b80a8dcc --- /dev/null +++ b/src/main/res/layout/activity_tv_audio_playback.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/src/main/res/layout/activity_tv_video_playback.xml b/src/main/res/layout/activity_tv_video_playback.xml new file mode 100644 index 000000000..c5b592c76 --- /dev/null +++ b/src/main/res/layout/activity_tv_video_playback.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/src/main/res/layout/activity_tv_web.xml b/src/main/res/layout/activity_tv_web.xml new file mode 100644 index 000000000..bd75464d1 --- /dev/null +++ b/src/main/res/layout/activity_tv_web.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/main/res/layout/container_files_dummy.xml b/src/main/res/layout/container_files_dummy.xml new file mode 100644 index 000000000..5efe51528 --- /dev/null +++ b/src/main/res/layout/container_files_dummy.xml @@ -0,0 +1,6 @@ + + diff --git a/src/main/res/layout/intro_first_layout.xml b/src/main/res/layout/intro_first_layout.xml new file mode 100644 index 000000000..84608690a --- /dev/null +++ b/src/main/res/layout/intro_first_layout.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/onboarding_image.xml b/src/main/res/layout/onboarding_image.xml new file mode 100644 index 000000000..39f21d9d6 --- /dev/null +++ b/src/main/res/layout/onboarding_image.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src/main/res/layout/percentage_view.xml b/src/main/res/layout/percentage_view.xml new file mode 100644 index 000000000..6bf826208 --- /dev/null +++ b/src/main/res/layout/percentage_view.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/src/main/res/layout/seek_view.xml b/src/main/res/layout/seek_view.xml new file mode 100644 index 000000000..c15a47b53 --- /dev/null +++ b/src/main/res/layout/seek_view.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/main/res/layout/subtitles_surface.xml b/src/main/res/layout/subtitles_surface.xml new file mode 100644 index 000000000..61a997c12 --- /dev/null +++ b/src/main/res/layout/subtitles_surface.xml @@ -0,0 +1,4 @@ + + diff --git a/src/main/res/layout/tv_header_item.xml b/src/main/res/layout/tv_header_item.xml new file mode 100644 index 000000000..56ca3b84b --- /dev/null +++ b/src/main/res/layout/tv_header_item.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/src/main/res/layout/tv_loading.xml b/src/main/res/layout/tv_loading.xml new file mode 100644 index 000000000..98495a36a --- /dev/null +++ b/src/main/res/layout/tv_loading.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/src/main/res/layout/upload_bottom_sheet.xml b/src/main/res/layout/upload_bottom_sheet.xml new file mode 100644 index 000000000..529b0c390 --- /dev/null +++ b/src/main/res/layout/upload_bottom_sheet.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/src/main/res/layout/upload_list_item.xml b/src/main/res/layout/upload_list_item.xml new file mode 100644 index 000000000..7451c12d0 --- /dev/null +++ b/src/main/res/layout/upload_list_item.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/src/main/res/layout/view_server_file_item.xml b/src/main/res/layout/view_server_file_item.xml index d07ae6381..ac5f1291a 100644 --- a/src/main/res/layout/view_server_file_item.xml +++ b/src/main/res/layout/view_server_file_item.xml @@ -24,13 +24,14 @@ android:foreground="?android:attr/selectableItemBackgroundBorderless" android:orientation="horizontal" android:padding="8dp"> - + + android:scaleType="centerCrop" + android:src="@drawable/ic_file_audio" /> + +

+ + + + diff --git a/src/main/res/menu/action_bar_expanded_controller.xml b/src/main/res/menu/action_bar_expanded_controller.xml new file mode 100644 index 000000000..c7cd62850 --- /dev/null +++ b/src/main/res/menu/action_bar_expanded_controller.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/src/main/res/menu/action_bar_server_file_image.xml b/src/main/res/menu/action_bar_server_file_image.xml index 83c518619..af2fc13f5 100644 --- a/src/main/res/menu/action_bar_server_file_image.xml +++ b/src/main/res/menu/action_bar_server_file_image.xml @@ -23,7 +23,16 @@ - \ No newline at end of file + + + + diff --git a/src/main/res/menu/action_bar_server_files.xml b/src/main/res/menu/action_bar_server_files.xml index b56c31734..e2aff5c65 100644 --- a/src/main/res/menu/action_bar_server_files.xml +++ b/src/main/res/menu/action_bar_server_files.xml @@ -34,4 +34,11 @@ android:title="@string/menu_sort" app:showAsAction="always" /> - \ No newline at end of file + + + diff --git a/src/main/res/menu/action_mode_server_files.xml b/src/main/res/menu/action_mode_server_files.xml index 83c518619..dd61fbb56 100644 --- a/src/main/res/menu/action_mode_server_files.xml +++ b/src/main/res/menu/action_mode_server_files.xml @@ -26,4 +26,10 @@ android:title="@string/menu_share" app:showAsAction="always" /> - \ No newline at end of file + + + diff --git a/src/main/res/values-v21/themes.xml b/src/main/res/values-v21/themes.xml index 9a1fb4f0e..2cfa88aca 100644 --- a/src/main/res/values-v21/themes.xml +++ b/src/main/res/values-v21/themes.xml @@ -1,14 +1,14 @@ - - - + + + + - \ No newline at end of file + diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index 1a98a714a..41eff2c50 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -34,4 +34,12 @@ #cc000000 #80000000 #deffffff + + + #0277bd + #3949ab + + #26a69a + #ffab00 + #303f9f \ No newline at end of file diff --git a/src/main/res/values/preferences.xml b/src/main/res/values/preferences.xml index 92a9cb623..0792bc9a6 100644 --- a/src/main/res/values/preferences.xml +++ b/src/main/res/values/preferences.xml @@ -22,6 +22,7 @@ About Account Settings + Auto Upload Feedback Rate @@ -29,6 +30,13 @@ Sign out Connection Tell a friend + Auto Upload + + HDA Server + Share + Path + Allow on Cellular Data + Autodetect Remote @@ -43,6 +51,13 @@ remote local tell_a_friend + auto_upload_screen + + auto_upload_switch + auto_upload_hda + auto_upload_share + auto_upload_path + auto_upload_data @string/preference_entry_server_connection_auto @@ -56,4 +71,35 @@ @string/preference_key_server_connection_local + + Select server + Choose the server whose shares you want to access. + List of active servers + Choose the active server whose shares you want to access. + select_server + + + Sign out + Account + Sign out from Amahi TV + Action is not defined + + + theme selected + Select theme + Theme + Select from Light or Dark + Select the type of connection you want. You can set it as Amahi Light or Amahi Dark + Amahi light + Amahi dark + + + Connection + Select from Autodetect, Remote or LAN. + Autodetect + Local area network + Remote + Go back + Select the type of connection you want. You can set it as Auto detect, remote or local area network. + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index b386842c0..ebce1b12c 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -24,9 +24,13 @@ Apps Settings Shares + Upload Search Google Play Sign in + Yes + No + Retry Password Username @@ -37,8 +41,12 @@ Open navigation Settings Share + Delete Sort Search + Upload File + + cast No Apps No Files @@ -51,6 +59,7 @@ Username or Password field cannot be left empty Connection Error Tap to retry + Error playing the video Check your settings and try again Create some and try again @@ -60,6 +69,21 @@ Create some and try again Connection Error + Confirm File Delete? + This action cannot be undone. + Deleting + Some error occurred while deleting file + Select Media Files to Upload + Uploading your file + File uploaded successfully + There was some error in uploading your file + Overwrite existing file? + The file %1$s already exists.\nAre you sure you want to replace the existing file? + Uploading new image + File %1$s + Upload Complete + Upload failed + Check out my Amahi home server! I use the Amahi Home Server for storing, backing up and streaming all my files.\n\nCheck it out!\n\nhttps://www.amahi.org/ Amahi for Android @@ -67,11 +91,61 @@ No Handler Application Found Logged out successfully Share Using - Storage permission has been enabled please reshare to download. - You have denied the permissions for sharing. Enable permissions to continue. + Storage permission is required to download the file + You have denied permissions for storage. Enable to continue + File read permission is required to access files on your device + You have denied permissions for reading files. Enable to continue + Camera access is required to capture photos and videos + Could not access the camera. Permissions denied + Previous Next Play Pause + Play on… + + Use Camera + Upload Photos / Videos + + + Amahi TV + INTENT_FILE + INTENT_SHARE + INTENT_SERVERS + PREFERENCE + isFirstRun + Exit + Are you sure you want to quit? + Yes + No + + Servers + Shares + Apps + Preferences + + + Welcome to Amahi for Android TV + Welcome to Amahi for Android. + An Android app that lets you access the files in your Amahi Home Server. + + Access your HDA + Amahi lets you play your videos, view your photos, listen to your music and more! + + Browse Your Photos + Browse your photo library in your TV, fast and at amazing resolution. + Browse your photo library easily, upload photos directly from your phone. + + Play Your Music + Listen to your music library in your TV, perfect for parties, or for relaxing with your favorite tunes. + Listen to your music library, at home or on the road. + + Play your Movies and Video Library + Watch your video and movie library on your big screen in amazing full resolution. + Watch your video and movie library on your phone any time anywhere. + + Ready + You\'re all set to go. Thanks for using Amahi. + diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index a8d3297f2..3a614befa 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -63,4 +63,12 @@ @color/background_primary @color/primary_text_material_light + + + + diff --git a/src/main/res/xml/file_paths.xml b/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..808320e16 --- /dev/null +++ b/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/src/main/res/xml/settings.xml b/src/main/res/xml/settings.xml index e7197a550..8d54a0ef5 100644 --- a/src/main/res/xml/settings.xml +++ b/src/main/res/xml/settings.xml @@ -36,6 +36,10 @@ android:key="@string/preference_key_server_connection" android:title="@string/preference_title_server_connection" /> + + diff --git a/src/main/res/xml/upload_settings.xml b/src/main/res/xml/upload_settings.xml new file mode 100644 index 000000000..a11f5af7d --- /dev/null +++ b/src/main/res/xml/upload_settings.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/src/test/java/org/amahi/anywhere/PermissionTest.java b/src/test/java/org/amahi/anywhere/PermissionTest.java index b5803238a..f94f5b7da 100644 --- a/src/test/java/org/amahi/anywhere/PermissionTest.java +++ b/src/test/java/org/amahi/anywhere/PermissionTest.java @@ -14,7 +14,8 @@ /** * Test for checking permissions in {@link AndroidManifest} */ -@RunWith(RobolectricTestRunner.class) // for the test suite when we will want to run this from the test suite +@RunWith(RobolectricTestRunner.class) +// for the test suite when we will want to run this from the test suite @Config(constants = BuildConfig.class, sdk = 23) public class PermissionTest { @@ -25,16 +26,16 @@ public void permissionCheck() { //List of expected permissions to be present in AndroidManifest.xml String[] expectedPermissions = { - "android.permission.ACCESS_NETWORK_STATE", - "android.permission.DOWNLOAD_WITHOUT_NOTIFICATION", - "android.permission.INTERNET", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE", - "android.permission.AUTHENTICATE_ACCOUNTS", - "android.permission.GET_ACCOUNTS", - "android.permission.MANAGE_ACCOUNTS", - "android.permission.USE_CREDENTIALS", - "android.permission.WAKE_LOCK" + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.DOWNLOAD_WITHOUT_NOTIFICATION", + "android.permission.INTERNET", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.AUTHENTICATE_ACCOUNTS", + "android.permission.GET_ACCOUNTS", + "android.permission.MANAGE_ACCOUNTS", + "android.permission.USE_CREDENTIALS", + "android.permission.WAKE_LOCK" }; //Checking expected permissions one by one @@ -51,8 +52,8 @@ public void permissionCheck() { private void showError(String permission) { Description description = new StringDescription(); description.appendText("Expected permission ") - .appendText(permission) - .appendText(" is missing from AndroidManifest.xml"); + .appendText(permission) + .appendText(" is missing from AndroidManifest.xml"); throw new AssertionError(description.toString()); }