niedziela, 2 października 2016

Xamarin.Forms kontra Xamarin odc.3 (uprawnienia, media library, dependency service)

Xamarin.Forms kontra Xamarin, witam serdecznie. Przed nami kolejny odcinek z luksusowej różowej edycji. Dziś będzie dość zwięźle i krótko. Generalnie odpowiemy na dwa pytania. Pierwsze: jak pobrać listę utworów muzycznych, by dało się ją wyświetlić na liście w Xamarin.Forms? I drugie: jak uporać się z uprawnieniami, do których nie ma uniwersalnej infrastruktury? No to zaczynamy!

Na obecny moment app-ka z LightOrgan/Xamarin.Forms ładuje listę kawałków muzycznych na Android i iOS:

Screenshot_20161002-221607  IMG_0061

Ktoś powie, że to może niewielki przyrost od ostatniego razu, ale ostatnio miałem nieco mniej czasu, a poza tym obsługa uprawnień w Xamarin.Forms wymagała chwilki zastanowienia (czemu nie ma jakiegoś uniwersalnego API?). Koniec końców natrafiłem na świetny post Simplified iOS & Android Runtime Permissions with Plugins!. Przedstawione tam rozwiązanie zostało zamknięte w pakiecie nuget Plugin.Permissions ze źródłami na https://github.com/jamesmontemagno/PermissionsPlugin, do których dopisałem sobie obsługę Media Library od iOS 9.3. Do enum-a Permission dodałem Media i obsłużyłem to jedynie w iOS wzorując się na obsłudze uprawnienia Photos. W klasie PermissionImplementation sprowadziło się to głównie do dodania takich elementów:

        PermissionStatus MediaPermissionStatus
        {
            get
            {
                if (UIDevice.CurrentDevice.CheckSystemVersion(9, 3))
                {
                    var status = MPMediaLibrary.AuthorizationStatus;
                    switch (status)
                    {
                        case MPMediaLibraryAuthorizationStatus.Authorized:
                            return PermissionStatus.Granted;
                        case MPMediaLibraryAuthorizationStatus.Denied:
                            return PermissionStatus.Denied;
                        case MPMediaLibraryAuthorizationStatus.Restricted:
                            return PermissionStatus.Restricted;
                        default:
                            return PermissionStatus.Unknown;
                    }
                }
                else
                {
                    return PermissionStatus.Granted;
                }                  
            }
        }

        Task<PermissionStatus> RequestMediaPermission()
        {
            if (UIDevice.CurrentDevice.CheckSystemVersion(9, 3))
            {
                if (MediaPermissionStatus != PermissionStatus.Unknown)
                    return Task.FromResult(MediaPermissionStatus);

                var tcs = new TaskCompletionSource<PermissionStatus>();

                MPMediaLibrary.RequestAuthorization(status =>
                {
                    switch (status)
                    {
                        case MPMediaLibraryAuthorizationStatus.Authorized:
                            tcs.SetResult(PermissionStatus.Granted);
                            break;
                        case MPMediaLibraryAuthorizationStatus.Denied:
                            tcs.SetResult(PermissionStatus.Denied);
                            break;
                        case MPMediaLibraryAuthorizationStatus.Restricted:
                            tcs.SetResult(PermissionStatus.Restricted);
                            break;
                        default:
                            tcs.SetResult(PermissionStatus.Unknown);
                            break;
                    }
                });

                return tcs.Task;
            }
            else
            {
                return Task.FromResult(PermissionStatus.Granted);
            }           
        }

Uprawnienia sprawdzam w aplikacji Xamarin.Forms podczas jej uruchamiania i wznawiania. W klasie App wygląda to tak:

        protected override async void OnStart()
        {
            await CheckPermissions();
        }

        …

        protected override async void OnResume()
        {
            await CheckPermissions();
        }

        private async Task CheckPermissions()
        {
            try
            {
                var permissionsList = new List<Permission>();

                if (Device.OS == TargetPlatform.Android)
                {
                    var status = await CrossPermissions.Current.CheckPermissionStatusAsync(Permission.Storage);

                    if (status != PermissionStatus.Granted)
                        permissionsList.Add(Permission.Storage);

                    status = await CrossPermissions.Current.CheckPermissionStatusAsync(Permission.Microphone);

                    if (status != PermissionStatus.Granted)
                        permissionsList.Add(Permission.Microphone);                  
                }
                else if (Device.OS == TargetPlatform.iOS)
                {
                    var status = await CrossPermissions.Current.CheckPermissionStatusAsync(Permission.Media);

                    if (status != PermissionStatus.Granted)
                        permissionsList.Add(Permission.Media);
                }

                if (permissionsList.Count > 0)
                {
                    var results = await CrossPermissions.Current.RequestPermissionsAsync(permissionsList.ToArray());

                    if (results.Any(p => p.Value != PermissionStatus.Granted))
                        await MainPage.DisplayAlert(AppResources.Permissions, AppResources.NotAllPermissionsMsg, "OK");
                }
            }
            catch (Exception ex)
            {               
            }
        }

Dodatkowo uprawnienia sprawdzam też na stronie FileListPage. Mankamentem stron w Xamarin.Forms jest fakt, że nie mają zdarzenia czy metody wywoływanej tuż po załadowaniu bądź wyświetleniu strony. Skorzystałem z metody OnAppearing, która - jak nazwa wskazuje - wywołuje się tuż przed wyświetleniem strony:

       protected override async void OnAppearing()
       {
           base.OnAppearing();

           var hasPermission = await CheckPermission();

           if (hasPermission)
           {
               allMediaItems = await DependencyService.Get<IMusicService>().GetItemsAsync();                 
           }
           else
           {
               allMediaItems = new List<MediaItem>();

               await DisplayAlert(AppResources.Permissions, string.Format(AppResources.PermissionDeniedMsg, GetPermissionName()), "OK");               
           }

           SearchFiles();
       }

       private async Task<bool> CheckPermission()
       {
           var status = PermissionStatus.Denied;

           if (Device.OS == TargetPlatform.Android)
              status = await CrossPermissions.Current.CheckPermissionStatusAsync(Permission.Storage);
           else if (Device.OS == TargetPlatform.iOS)
              status = await CrossPermissions.Current.CheckPermissionStatusAsync(Permission.Media);

           return status == PermissionStatus.Granted;
       }

       private string GetPermissionName()
       {
           if (Device.OS == TargetPlatform.Android)
               return "READ_EXTERNAL_STORAGE";
           else if (Device.OS == TargetPlatform.iOS)
               return "Media library access";

           return string.Empty;
       }

Widzimy tutaj wywołanie tzw. dependency service, czyli serwisu w Xamarin.Forms, który ma różną implementację na każdej z platform (u mnie Android i iOS). Jego interfejs składa się póki co z jednej metody:

   public interface IMusicService
   {
      Task<List<MediaItem>> GetItemsAsync();
   }

Implementacja na Android przedstawia się następująco:

[assembly: Xamarin.Forms.Dependency(typeof(AndroidMusicService))]

namespace LightOrganApp.Droid
{
    public class AndroidMusicService : IMusicService
    {
        public async Task<List<MediaItem>> GetItemsAsync()
        {
            return await Task.Run(() =>
            {
                var items = new List<MediaItem>();

                try
                {
                    var projection = new string[]
                    {
                        MediaStore.Audio.Media.InterfaceConsts.Id,
                        MediaStore.Audio.Media.InterfaceConsts.Artist,
                        MediaStore.Audio.Media.InterfaceConsts.Title,
                        MediaStore.Audio.Media.InterfaceConsts.Duration,
                        MediaStore.Audio.Media.InterfaceConsts.Data,
                        MediaStore.Audio.Media.InterfaceConsts.MimeType
                    };

                    var selection = MediaStore.Audio.Media.InterfaceConsts.IsMusic + "!= 0";
                    var sortOrder = MediaStore.Audio.Media.InterfaceConsts.DateAdded + " DESC";

                    var cursor = Forms.Context.ContentResolver.Query(MediaStore.Audio.Media.ExternalContentUri, projection, selection, null, sortOrder);

                    if (cursor != null && cursor.MoveToFirst())
                    {
                        do
                        {
                            int artistColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Artist);
                            int titleColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Title);
                            int durationColumn = cursor.GetColumnIndex(MediaStore.Audio.Media.InterfaceConsts.Duration);                           

                            var item = new MediaItem(cursor.GetString(titleColumn), cursor.GetString(artistColumn), DateUtils.FormatElapsedTime(cursor.GetInt(durationColumn) / 1000));
                            items.Add(item);                         

                        } while (cursor.MoveToNext());
                    }                       
                }
                catch
                {                  
                }

                return items;
            });           
        }      
    }
}

a na iOS tak:

[assembly: Xamarin.Forms.Dependency(typeof(iOSMusicService))]

namespace LightOrganApp.iOS
{
    public class iOSMusicService : IMusicService
    {
        public async Task<List<MediaItem>> GetItemsAsync()
        {
            return await Task.Run(() =>
            {
                var query = new MPMediaQuery();
                var mediaTypeNumber = NSNumber.FromInt32((int)MPMediaType.Music);
                var predicate = MPMediaPropertyPredicate.PredicateWithValue(mediaTypeNumber, MPMediaItem.MediaTypeProperty);

                query.AddFilterPredicate(predicate);

                var unknownArtist = NSBundle.MainBundle.LocalizedString("unknownArtist", "Unknown Artist");

                return query.Items.Select(item => new MediaItem(item.Title, (item.Artist != null) ? item.Artist : unknownArtist, GetDisplayTime((int)item.PlaybackDuration))).ToList();
            });         
        }

        private string GetDisplayTime(int seconds)
        {
            var h = seconds / 3600;
            var m = seconds / 60 - h * 60;
            var s = seconds - h * 3600 - m * 60;

            var str = "";

            if (h > 0)
                str += $"{h}:";

            str += $"{m:00}:{s:00}";

            return str;
        }
    }
}

Podsumowując udało się nam współdzielić maksymalnie wszystko, co się dało w aplikacji Xamarin.Forms, rozdzielając jedynie szczegóły implementacyjne tam, gdzie było to niezbędne. To oczywiście dopiero wstęp do podpinania kodu opartego na natywnym API. W kolejnym kroku pasuje podpiąć odtwarzanie muzyki wraz z odpowiednimi notyfikacjami, a także z jawnym serwisem w tle w przypadku Android ( iOS  - jak dotąd w moich projektach -  używa systemowego odtwarzacza, który robi to w sposób niejawny). Ewentualnie może od razu podepnie się analizę FFT opracowaną jak dotąd w Android do sterowania światłami. Co z tego wyjdzie?  O tym w następnym odcinku. Stay tuned!