feat(android): share podcast playback and foreground broadcasting
Add native Camera2 capture, MPEG-TS segmentation and foreground service with notification stop. Reuse shared authenticated uploads and replay finalization, add capture regression tests and document platform limitations. Update Avalonia to 12.1.4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
2dcbcd35b3
commit
ad92744a94
24 changed files with 1495 additions and 70 deletions
|
|
@ -25,6 +25,7 @@ public class MainActivity : AvaloniaMainActivity
|
|||
/// The current MainActivity instance.
|
||||
/// </summary>
|
||||
public static MainActivity? Current { get; private set; }
|
||||
internal static bool IsForeground { get; private set; }
|
||||
|
||||
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
|
||||
{
|
||||
|
|
@ -36,8 +37,8 @@ public class MainActivity : AvaloniaMainActivity
|
|||
EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
|
||||
EmojiCompat.Init(config);
|
||||
PlatformBootstrap.InitPlatform();
|
||||
base.OnCreate(savedInstanceState);
|
||||
Current = this;
|
||||
base.OnCreate(savedInstanceState);
|
||||
}
|
||||
/// <summary>
|
||||
/// Receives the deep-link Intent fired by the system browser after the
|
||||
|
|
@ -60,9 +61,34 @@ public class MainActivity : AvaloniaMainActivity
|
|||
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults)
|
||||
protected override void OnStop()
|
||||
{
|
||||
if (PostIt.Android.Services.AndroidCurrentLocationProvider
|
||||
PostIt.Services.Platform.SuspendPlayback();
|
||||
base.OnStop();
|
||||
}
|
||||
|
||||
protected override void OnResume()
|
||||
{
|
||||
base.OnResume();
|
||||
IsForeground = true;
|
||||
}
|
||||
|
||||
protected override void OnPause()
|
||||
{
|
||||
IsForeground = false;
|
||||
base.OnPause();
|
||||
}
|
||||
|
||||
protected override void OnRestart()
|
||||
{
|
||||
base.OnRestart();
|
||||
PostIt.Services.Platform.ResumePlayback();
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
|
||||
{
|
||||
if (PostIt.Android.Services.PodcastPermissions.HandleResult(requestCode, permissions, grantResults)
|
||||
|| PostIt.Android.Services.AndroidCurrentLocationProvider
|
||||
.HandlePermissionResult(requestCode, grantResults))
|
||||
{
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -21,5 +21,12 @@ internal static class PlatformBootstrap
|
|||
};
|
||||
|
||||
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
|
||||
Platform.CreateVideoSurface = player => new AndroidVideoSurface(player);
|
||||
if (System.OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
{
|
||||
Platform.GetPodcastCaptureDevices = AndroidPodcastCapture.GetDevices;
|
||||
Platform.RequestPodcastPermissionsAsync = AndroidPodcastCapture.RequestPermissionsAsync;
|
||||
Platform.CreatePodcastCaptureAsync = AndroidPodcastCapture.StartAsync;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,17 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" />
|
||||
<PackageReference Include="LibVLCSharp" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Android" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Browser" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<Compile Include="..\PostIt.Tests\AndroidPodcastCaptureTestActivity.cs" Link="Tests\AndroidPodcastCaptureTestActivity.cs" />
|
||||
</ItemGroup>
|
||||
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
|
||||
extension's injected HotReload agent (references Microsoft.Maui.*).
|
||||
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
<application
|
||||
android:label="PostIt"
|
||||
android:icon="@drawable/Icon"
|
||||
|
|
|
|||
309
src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.cs
Normal file
309
src/PostIt/PostIt.Android/Services/AndroidPodcastCapture.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.Content;
|
||||
using Android.Hardware.Camera2;
|
||||
using Android.Hardware.Camera2.Params;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
internal sealed class AndroidPodcastCapture : IPodcastCapture
|
||||
{
|
||||
private readonly PodcastCaptureService _service;
|
||||
private readonly SemaphoreSlim _stopGate = new(1);
|
||||
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly Handler _handler = new(Looper.MainLooper!);
|
||||
private CameraDevice? _camera;
|
||||
private CameraCaptureSession? _session;
|
||||
private MediaRecorder? _recorder;
|
||||
private Surface? _surface;
|
||||
private ParcelFileDescriptor? _readPipe;
|
||||
private ParcelFileDescriptor? _writePipe;
|
||||
private Task _pump = Task.CompletedTask;
|
||||
private Exception? _failure;
|
||||
private bool _stopped;
|
||||
private bool _stopRequested;
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
public Task Completion => _completion.Task;
|
||||
|
||||
private AndroidPodcastCapture(PodcastCaptureService service) => _service = service;
|
||||
|
||||
private static CameraManager GetManager(Context context)
|
||||
=> context.GetSystemService(Context.CameraService) as CameraManager
|
||||
?? throw new InvalidOperationException("Service camera Android indisponible.");
|
||||
|
||||
public static PodcastCaptureDevices GetDevices()
|
||||
{
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
var manager = GetManager(activity);
|
||||
var cameras = new List<CaptureDevice>();
|
||||
foreach (var id in manager.GetCameraIdList())
|
||||
{
|
||||
using var characteristics = manager.GetCameraCharacteristics(id);
|
||||
using var facing = characteristics.Get(CameraCharacteristics.LensFacing) as Java.Lang.Integer;
|
||||
cameras.Add(new(facing?.IntValue() == (int)LensFacing.Front ? "Camera avant" : "Camera arriere", id));
|
||||
}
|
||||
return new(cameras, [new("Sans microphone", ""), new("Microphone Android", "microphone")]);
|
||||
}
|
||||
|
||||
public static async Task RequestPermissionsAsync(string? microphone, CancellationToken ct)
|
||||
{
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
await PodcastPermissions.EnsureAsync(activity, microphone is not null, ct);
|
||||
if (!MainActivity.IsForeground)
|
||||
throw new InvalidOperationException("Revenez dans PostIt pour demarrer la camera.");
|
||||
}
|
||||
|
||||
public static async Task<IPodcastCapture> StartAsync(string camera, string? microphone, string directory, CancellationToken ct)
|
||||
{
|
||||
await RequestPermissionsAsync(microphone, ct);
|
||||
var activity = MainActivity.Current ?? throw new InvalidOperationException("Activite Android indisponible.");
|
||||
var service = await PodcastCaptureService.StartAsync(activity, microphone is not null, ct);
|
||||
var capture = new AndroidPodcastCapture(service);
|
||||
service.Capture = capture;
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await capture.StartNativeAsync(camera, microphone is not null, directory, ct);
|
||||
return capture;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await capture.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
|
||||
{
|
||||
await _stopGate.WaitAsync(ct);
|
||||
try { await ConfigureNativeAsync(cameraId, microphone, directory, ct); }
|
||||
finally { _stopGate.Release(); }
|
||||
}
|
||||
|
||||
private async Task ConfigureNativeAsync(string cameraId, bool microphone, string directory, CancellationToken ct)
|
||||
{
|
||||
var manager = GetManager(_service);
|
||||
using var characteristics = manager.GetCameraCharacteristics(cameraId);
|
||||
using var formats = characteristics.Get(CameraCharacteristics.ScalerStreamConfigurationMap) as StreamConfigurationMap
|
||||
?? throw new InvalidOperationException("Formats camera indisponibles.");
|
||||
var sizes = formats.GetOutputSizes(Java.Lang.Class.FromType(typeof(MediaRecorder)))
|
||||
?? throw new InvalidOperationException("Aucun format video disponible.");
|
||||
var size = sizes.Where(s => s.Width <= 640 && s.Height <= 480)
|
||||
.OrderByDescending(s => s.Width * s.Height).FirstOrDefault()
|
||||
?? throw new InvalidOperationException("La camera ne propose aucun format jusqu'a 480p.");
|
||||
#pragma warning disable CS0618
|
||||
_recorder = OperatingSystem.IsAndroidVersionAtLeast(31)
|
||||
? new MediaRecorder(_service) : new MediaRecorder();
|
||||
#pragma warning restore CS0618
|
||||
_recorder.Error += OnRecorderError;
|
||||
if (microphone) _recorder.SetAudioSource(AudioSource.Mic);
|
||||
_recorder.SetVideoSource(VideoSource.Surface);
|
||||
_recorder.SetOutputFormat(OutputFormat.Mpeg2Ts);
|
||||
_recorder.SetVideoSize(size.Width, size.Height);
|
||||
_recorder.SetVideoFrameRate(30);
|
||||
_recorder.SetVideoEncodingBitRate(1200000);
|
||||
_recorder.SetVideoEncoder(VideoEncoder.H264);
|
||||
if (microphone)
|
||||
{
|
||||
_recorder.SetAudioSamplingRate(48000);
|
||||
_recorder.SetAudioChannels(1);
|
||||
_recorder.SetAudioEncodingBitRate(96000);
|
||||
_recorder.SetAudioEncoder(AudioEncoder.Aac);
|
||||
}
|
||||
var pipe = ParcelFileDescriptor.CreatePipe()
|
||||
?? throw new IOException("Creation du canal MPEG-TS impossible.");
|
||||
_readPipe = pipe[0];
|
||||
_writePipe = pipe[1];
|
||||
_recorder.SetOutputFile(_writePipe.FileDescriptor);
|
||||
await Task.Run(_recorder.Prepare, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
|
||||
_surface = _recorder.Surface ?? throw new InvalidOperationException("Surface encodeur indisponible.");
|
||||
var opened = new TaskCompletionSource<CameraDevice>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
manager.OpenCamera(cameraId, new DeviceCallback(this, opened), _handler);
|
||||
_camera = await opened.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
var configured = new TaskCompletionSource<CameraCaptureSession>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
#pragma warning disable CS0618, CA1422
|
||||
_camera.CreateCaptureSession(new List<Surface> { _surface }, new SessionCallback(this, configured), _handler);
|
||||
#pragma warning restore CS0618, CA1422
|
||||
_session = await configured.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_stopRequested) throw new System.OperationCanceledException("Demarrage de la capture annule.");
|
||||
using var request = _camera.CreateCaptureRequest(CameraTemplate.Record);
|
||||
request.AddTarget(_surface);
|
||||
using var built = request.Build();
|
||||
_session.SetRepeatingRequest(built, null, _handler);
|
||||
var handle = new SafeFileHandle((IntPtr)_readPipe.Fd, ownsHandle: false);
|
||||
var stream = new FileStream(handle, FileAccess.Read);
|
||||
_pump = Task.Run(async () =>
|
||||
{
|
||||
using (handle)
|
||||
using (stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
await new TransportStreamCapture(directory).RunAsync(stream, CancellationToken.None);
|
||||
if (_failure is not null) throw new IOException("Capture Android interrompue.", _failure);
|
||||
if (!_stopped) throw new IOException("Le flux de capture Android s'est ferme prematurement.");
|
||||
_completion.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_completion.TrySetException(ex);
|
||||
// Drain until MediaRecorder stops, so its writer cannot block Stop().
|
||||
await stream.CopyToAsync(System.IO.Stream.Null);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Task.Run(_recorder.Start, ct);
|
||||
_started = true;
|
||||
_writePipe.Close();
|
||||
_writePipe.Dispose();
|
||||
_writePipe = null;
|
||||
}
|
||||
|
||||
private sealed class DeviceCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraDevice> opened)
|
||||
: CameraDevice.StateCallback
|
||||
{
|
||||
public override void OnOpened(CameraDevice camera)
|
||||
{
|
||||
if (capture._stopRequested || opened.Task.IsCompleted)
|
||||
{
|
||||
camera.Close();
|
||||
opened.TrySetException(new IOException("Ouverture camera annulee."));
|
||||
}
|
||||
else
|
||||
{
|
||||
capture._camera = camera;
|
||||
opened.TrySetResult(camera);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnDisconnected(CameraDevice camera) => Fail(camera, "Camera Android deconnectee.");
|
||||
public override void OnError(CameraDevice camera, CameraError error) => Fail(camera, $"Erreur camera Android : {error}.");
|
||||
private void Fail(CameraDevice camera, string message)
|
||||
{
|
||||
camera.Close();
|
||||
var error = new IOException(message);
|
||||
opened.TrySetException(error);
|
||||
capture.Fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionCallback(AndroidPodcastCapture capture, TaskCompletionSource<CameraCaptureSession> configured)
|
||||
: CameraCaptureSession.StateCallback
|
||||
{
|
||||
public override void OnConfigured(CameraCaptureSession session)
|
||||
{
|
||||
if (capture._stopRequested || configured.Task.IsCompleted)
|
||||
{
|
||||
session.Close();
|
||||
configured.TrySetException(new IOException("Configuration camera annulee."));
|
||||
}
|
||||
else
|
||||
{
|
||||
capture._session = session;
|
||||
configured.TrySetResult(session);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnConfigureFailed(CameraCaptureSession session)
|
||||
{
|
||||
session.Close();
|
||||
var error = new IOException("Configuration de la capture Android impossible.");
|
||||
configured.TrySetException(error);
|
||||
capture.Fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecorderError(object? sender, MediaRecorder.ErrorEventArgs e)
|
||||
=> Fail(new IOException($"Erreur encodeur Android : {e.What}/{e.Extra}."));
|
||||
|
||||
private void Fail(Exception error)
|
||||
{
|
||||
if (_stopped) return;
|
||||
_failure = error;
|
||||
_completion.TrySetException(error);
|
||||
RequestStop();
|
||||
}
|
||||
|
||||
internal async void RequestStop()
|
||||
{
|
||||
try { await StopAsync(); }
|
||||
catch (Exception ex) { global::Android.Util.Log.Error("PostIt.Podcast", ex.ToString()); }
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_stopRequested = true;
|
||||
await _stopGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_stopped) return;
|
||||
_stopped = true;
|
||||
await Task.Run(StopNative);
|
||||
await _pump;
|
||||
}
|
||||
finally { _stopGate.Release(); }
|
||||
}
|
||||
|
||||
private void StopNative()
|
||||
{
|
||||
try { if (_started) _recorder?.Stop(); }
|
||||
catch (Java.Lang.RuntimeException ex)
|
||||
{
|
||||
_failure = ex;
|
||||
_completion.TrySetException(new IOException("L'encodeur Android n'a pas pu finaliser la capture.", ex));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_started = false;
|
||||
_session?.Close();
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
_camera?.Close();
|
||||
_camera?.Dispose();
|
||||
_camera = null;
|
||||
if (_recorder is not null)
|
||||
{
|
||||
_recorder.Error -= OnRecorderError;
|
||||
_recorder.Release();
|
||||
_recorder.Dispose();
|
||||
_recorder = null;
|
||||
}
|
||||
_surface?.Dispose();
|
||||
_surface = null;
|
||||
_writePipe?.Close();
|
||||
_writePipe?.Dispose();
|
||||
_writePipe = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { await StopAsync(); }
|
||||
finally
|
||||
{
|
||||
_readPipe?.Dispose();
|
||||
_readPipe = null;
|
||||
_service.Capture = null;
|
||||
_service.StopSelf();
|
||||
_handler.Dispose();
|
||||
_stopGate.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs
Normal file
37
src/PostIt/PostIt.Android/Services/AndroidVideoSurface.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using Avalonia.Android;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using LibVLCSharp.Shared;
|
||||
using VideoView = LibVLCSharp.Platforms.Android.VideoView;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal sealed class AndroidVideoSurface(MediaPlayer player) : NativeControlHost, IDisposable
|
||||
{
|
||||
private VideoView? _video;
|
||||
private bool _disposed;
|
||||
|
||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
|
||||
{
|
||||
var activity = MainActivity.Current
|
||||
?? throw new InvalidOperationException("L'activite Android est indisponible.");
|
||||
var video = new VideoView(activity) { MediaPlayer = _disposed ? null : player };
|
||||
_video = video;
|
||||
return new AndroidViewControlHandle(video);
|
||||
}
|
||||
|
||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
if (control is AndroidViewControlHandle { View: VideoView video })
|
||||
video.MediaPlayer = null;
|
||||
_video = null;
|
||||
base.DestroyNativeControlCore(control);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
if (_video is not null) _video.MediaPlayer = null;
|
||||
}
|
||||
}
|
||||
109
src/PostIt/PostIt.Android/Services/PodcastCaptureService.cs
Normal file
109
src/PostIt/PostIt.Android/Services/PodcastCaptureService.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using AndroidX.Core.App;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
[Service(Exported = false, ForegroundServiceType = ForegroundService.TypeCamera | ForegroundService.TypeMicrophone)]
|
||||
[SupportedOSPlatform("android26.0")]
|
||||
public sealed class PodcastCaptureService : Service
|
||||
{
|
||||
private const string Channel = "podcast-broadcast";
|
||||
internal const string StopAction = "fr.pschneider.postit.STOP_PODCAST";
|
||||
private const int NotificationId = 4044;
|
||||
private static TaskCompletionSource<PodcastCaptureService>? _pending;
|
||||
private PowerManager.WakeLock? _wakeLock;
|
||||
internal AndroidPodcastCapture? Capture { get; set; }
|
||||
|
||||
public static async Task<PodcastCaptureService> StartAsync(Activity activity, bool microphone, CancellationToken ct)
|
||||
{
|
||||
if (_pending is not null) throw new InvalidOperationException("Un direct Android est deja en cours.");
|
||||
var pending = new TaskCompletionSource<PodcastCaptureService>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending = pending;
|
||||
try
|
||||
{
|
||||
var intent = new Intent(activity, typeof(PodcastCaptureService)).PutExtra("microphone", microphone);
|
||||
activity.StartForegroundService(intent);
|
||||
return await pending.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
activity.StopService(new Intent(activity, typeof(PodcastCaptureService)));
|
||||
_pending = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||
{
|
||||
if (intent?.Action == StopAction)
|
||||
{
|
||||
if (Capture is { } capture) capture.RequestStop();
|
||||
else
|
||||
{
|
||||
_pending?.TrySetException(new System.OperationCanceledException("Demarrage du direct annule depuis la notification."));
|
||||
StopSelf();
|
||||
}
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
try
|
||||
{
|
||||
var manager = GetSystemService(NotificationService) as NotificationManager
|
||||
?? throw new InvalidOperationException("Notifications Android indisponibles.");
|
||||
manager.CreateNotificationChannel(new NotificationChannel(Channel, "Diffusion podcast", NotificationImportance.Low));
|
||||
var launch = PackageManager?.GetLaunchIntentForPackage(PackageName!)
|
||||
?? throw new InvalidOperationException("Application Android indisponible.");
|
||||
var open = PendingIntent.GetActivity(this, 0, launch, PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||
var stop = PendingIntent.GetService(this, 1,
|
||||
new Intent(this, typeof(PodcastCaptureService)).SetAction(StopAction),
|
||||
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||
var builder = new NotificationCompat.Builder(this, Channel);
|
||||
builder.SetSmallIcon(global::Android.Resource.Drawable.IcMenuCamera);
|
||||
builder.SetContentTitle("Podcast en direct");
|
||||
builder.SetContentText("Camera active. Toucher pour revenir au direct.");
|
||||
builder.SetContentIntent(open);
|
||||
builder.SetOngoing(true);
|
||||
builder.AddAction(global::Android.Resource.Drawable.IcMediaPause, "Arreter et publier", stop);
|
||||
var notification = builder.Build();
|
||||
var type = (ForegroundService)0;
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(30))
|
||||
{
|
||||
type = ForegroundService.TypeCamera;
|
||||
if (intent?.GetBooleanExtra("microphone", false) == true) type |= ForegroundService.TypeMicrophone;
|
||||
}
|
||||
ServiceCompat.StartForeground(this, NotificationId, notification, (int)type);
|
||||
var power = GetSystemService(PowerService) as PowerManager
|
||||
?? throw new InvalidOperationException("Gestion d'alimentation Android indisponible.");
|
||||
_wakeLock = power.NewWakeLock(WakeLockFlags.Partial, "PostIt:Podcast")
|
||||
?? throw new InvalidOperationException("Le verrou d'alimentation Android est indisponible.");
|
||||
_wakeLock.Acquire();
|
||||
_pending?.TrySetResult(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pending?.TrySetException(ex);
|
||||
StopSelf();
|
||||
}
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
|
||||
public override IBinder? OnBind(Intent? intent) => null;
|
||||
|
||||
public override void OnDestroy()
|
||||
{
|
||||
_pending?.TrySetException(new InvalidOperationException("Le service de diffusion Android a ete arrete."));
|
||||
Capture?.RequestStop();
|
||||
Capture = null;
|
||||
if (_wakeLock?.IsHeld == true) _wakeLock.Release();
|
||||
_wakeLock?.Dispose();
|
||||
_wakeLock = null;
|
||||
_pending = null;
|
||||
base.OnDestroy();
|
||||
}
|
||||
}
|
||||
47
src/PostIt/PostIt.Android/Services/PodcastPermissions.cs
Normal file
47
src/PostIt/PostIt.Android/Services/PodcastPermissions.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Android;
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using AndroidX.Core.App;
|
||||
using AndroidX.Core.Content;
|
||||
|
||||
namespace PostIt.Android.Services;
|
||||
|
||||
internal static class PodcastPermissions
|
||||
{
|
||||
private const int RequestCode = 4043;
|
||||
private static TaskCompletionSource<bool>? _pending;
|
||||
private static string[] _requested = [];
|
||||
|
||||
public static async Task EnsureAsync(Activity activity, bool microphone, CancellationToken ct)
|
||||
{
|
||||
var requested = new List<string> { Manifest.Permission.Camera };
|
||||
if (microphone) requested.Add(Manifest.Permission.RecordAudio);
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(33)) requested.Add(Manifest.Permission.PostNotifications);
|
||||
var permissions = requested.ToArray();
|
||||
if (permissions.All(p => ContextCompat.CheckSelfPermission(activity, p) == Permission.Granted)) return;
|
||||
if (_pending is not null) throw new InvalidOperationException("Une demande de permissions est deja en cours.");
|
||||
_requested = permissions;
|
||||
var pending = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending = pending;
|
||||
ActivityCompat.RequestPermissions(activity, permissions, RequestCode);
|
||||
if (!await pending.Task.WaitAsync(ct))
|
||||
throw new UnauthorizedAccessException("Autorisez la camera, les notifications et, si selectionne, le microphone pour diffuser.");
|
||||
}
|
||||
|
||||
public static bool HandleResult(int requestCode, string[]? permissions, Permission[]? results)
|
||||
{
|
||||
if (requestCode != RequestCode) return false;
|
||||
var granted = permissions is not null && results is not null && permissions.Length == results.Length
|
||||
&& _requested.All(p => Array.IndexOf(permissions, p) is var index && index >= 0
|
||||
&& results[index] == Permission.Granted);
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.TrySetResult(granted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue