Eerste commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="MijnBesluit.App.App">
|
||||
</Application>
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly Terugknop _terugknop;
|
||||
|
||||
public App(Terugknop terugknop)
|
||||
{
|
||||
InitializeComponent();
|
||||
_terugknop = terugknop;
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
{
|
||||
var venster = new Window(new HoofdPagina(_terugknop)) { Title = "MijnBesluit" };
|
||||
|
||||
// Android verbreekt de socketverbinding zodra de app een tijdje op de achtergrond staat -
|
||||
// bijvoorbeeld terwijl iemand zijn e-mail leest om de inlogcode op te halen. Bij het
|
||||
// terugkomen halen we de gegevens opnieuw op, zodat de melding "geen verbinding"
|
||||
// verdwijnt zonder dat de gebruiker ergens op hoeft te drukken.
|
||||
venster.Resumed += async (_, _) =>
|
||||
{
|
||||
var diensten = IPlatformApplication.Current?.Services;
|
||||
|
||||
// Eerst kijken of de app zichzelf had moeten uitloggen terwijl hij in de achtergrond
|
||||
// stond. Dat is het geval waar automatisch uitloggen voor bedoeld is - een telefoon
|
||||
// die een uur in een tas zit - en het hoort te gebeuren vóór we gegevens ophalen.
|
||||
diensten?.GetService<Uitlogklok>()?.Hervatten();
|
||||
|
||||
var toestand = diensten?.GetService<AppToestand>();
|
||||
if (toestand is { IsAangemeld: true }) await toestand.VerversAsync();
|
||||
};
|
||||
|
||||
return venster;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@namespace MijnBesluit.App
|
||||
|
||||
@* De ingang van de app. De schermen zelf staan in MijnBesluit.AppUi, zodat website en app
|
||||
dezelfde blijven; hier wordt alleen verteld waar ze te vinden zijn. *@
|
||||
|
||||
<Router AppAssembly="@typeof(Hoofdscherm).Assembly"
|
||||
AdditionalAssemblies="new[] { typeof(MijnBesluit.AppUi.Schermen.Start).Assembly }">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(AppSchil)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(AppSchil)">
|
||||
<div class="leeg">
|
||||
<span class="teken">🤔</span>
|
||||
Dit scherm bestaat niet. Ga terug naar het <a href="/">overzicht</a>.
|
||||
</div>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -0,0 +1,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using MijnBesluit.App
|
||||
@using MijnBesluit.AppUi
|
||||
@using MijnBesluit.AppUi.Layout
|
||||
@using MijnBesluit.AppUi.Schermen
|
||||
@using MijnBesluit.Client
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
|
||||
xmlns:local="clr-namespace:MijnBesluit.App"
|
||||
x:Class="MijnBesluit.App.HoofdPagina"
|
||||
BackgroundColor="#faf7f2">
|
||||
|
||||
<!-- De hele app is één webview met de Blazor-schermen erin. Dezelfde schermen als op
|
||||
de website, zodat een wijziging maar op één plek hoeft. -->
|
||||
<b:BlazorWebView x:Name="webview" HostPage="wwwroot/index.html">
|
||||
<b:BlazorWebView.RootComponents>
|
||||
<b:RootComponent Selector="#app" ComponentType="{x:Type local:Hoofdscherm}" />
|
||||
</b:BlazorWebView.RootComponents>
|
||||
</b:BlazorWebView>
|
||||
|
||||
</ContentPage>
|
||||
@@ -0,0 +1,24 @@
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
public partial class HoofdPagina : ContentPage
|
||||
{
|
||||
private readonly Terugknop _terugknop;
|
||||
|
||||
public HoofdPagina(Terugknop terugknop)
|
||||
{
|
||||
InitializeComponent();
|
||||
_terugknop = terugknop;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// De terugknop van het toestel. Android stuurt hem hierheen; geven we false terug, dan doet
|
||||
/// het toestel wat het altijd deed en gaat de app dicht.
|
||||
///
|
||||
/// De schermen bepalen zelf wat terug betekent - een popup dicht, terug naar het overzicht -
|
||||
/// via <see cref="Terugknop"/>. Hier staat alleen de doorgifte, want de webview zelf weet
|
||||
/// niets van de knoppen van het toestel.
|
||||
/// </summary>
|
||||
protected override bool OnBackButtonPressed() => _terugknop.Ingedrukt();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MijnBesluit.AppUi;
|
||||
using MijnBesluit.Client;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
/// <summary>
|
||||
/// Waar de SocketServer draait. Voor het testen op een emulator wijst 10.0.2.2 naar je
|
||||
/// eigen pc; op een echt toestel vul je hier de servernaam in.
|
||||
/// </summary>
|
||||
public const string Server = "mijnbesluit.nl";
|
||||
public const int Poort = 5200;
|
||||
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder.UseMauiApp<App>();
|
||||
|
||||
builder.Services.AddMauiBlazorWebView();
|
||||
|
||||
// Eén verbinding en één sessie voor de hele app: alle schermen delen hem.
|
||||
builder.Services.AddSingleton<ITokenopslag, ToestelTokenopslag>();
|
||||
builder.Services.AddSingleton(sp => new MijnBesluitClient(
|
||||
Server, Poort, tls: true, sp.GetRequiredService<ITokenopslag>()));
|
||||
builder.Services.AddSingleton(sp => new AppToestand(
|
||||
sp.GetRequiredService<MijnBesluitClient>(), sp.GetRequiredService<IMeldingen>()));
|
||||
|
||||
// Het kiezen van bestanden en het opnemen van video: dat kan de webview niet zelf.
|
||||
builder.Services.AddSingleton<IBestandskiezer, Toestelbestanden>();
|
||||
|
||||
// Een advertentie opent bij de adverteerder, in de browser van de telefoon en niet in de
|
||||
// app. Ook dat kan de webview niet zelf.
|
||||
builder.Services.AddSingleton<IBuitenlink, Toestelbuitenlink>();
|
||||
|
||||
// De terugknop van het toestel. Eén exemplaar voor de hele app: de pagina geeft de druk
|
||||
// erin, de schermen in de webview melden zich erop aan.
|
||||
builder.Services.AddSingleton<Terugknop>();
|
||||
|
||||
// Het "trek naar beneden om te verversen"-gebaar: de schil meldt hier dat er is
|
||||
// getrokken, het scherm dat op dat moment openstaat luistert en haalt zijn gegevens
|
||||
// opnieuw op. Zie VernieuwSignaal.
|
||||
builder.Services.AddSingleton<VernieuwSignaal>();
|
||||
|
||||
// Het slot van het toestel: vingerafdruk, gezicht of de code van de telefoon zelf.
|
||||
builder.Services.AddSingleton<IToestelslot, Toestelslot>();
|
||||
|
||||
// Instellingen die alleen op dit toestel gelden, en de klok die de app zichzelf laat
|
||||
// vergrendelen. Eén exemplaar, want de schil bewaakt hem en het instellingenscherm zet hem.
|
||||
//
|
||||
// De standaardtermijn hangt af van het toestel: kan de telefoon om een vingerafdruk of
|
||||
// code vragen, dan gaat de klok standaard aan - dan kost vergeten je één aanraking.
|
||||
// Kan dat niet, dan kost vergeten je een e-mail met een code, en dat leggen we niemand
|
||||
// ongevraagd op.
|
||||
builder.Services.AddSingleton<IAppinstellingen, Toestelinstellingen>();
|
||||
builder.Services.AddSingleton(sp => new Uitlogklok(
|
||||
sp.GetRequiredService<IAppinstellingen>(),
|
||||
sp.GetRequiredService<IToestelslot>().Beschikbaar ? Uitlogklok.StandaardMetSlot : 0,
|
||||
sp.GetRequiredService<IToestelslot>().Beschikbaar));
|
||||
|
||||
// Meldingen op het toestel. Zonder Firebase levert dit niets op en werkt de app
|
||||
// gewoon door; zie docs/Meldingen.md.
|
||||
builder.Services.AddSingleton<IMeldingen, Toestelmeldingen>();
|
||||
|
||||
#if DEBUG
|
||||
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||
#endif
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- De schermen zijn voor allebei dezelfde; alleen wat het toestel zelf doet verschilt.
|
||||
Zie Platforms\ en de #if-blokken in Toestelslot.cs en Toestelmeldingen.cs.
|
||||
|
||||
iOS staat standaard UIT, en dat is geen halfheid maar de enige werkbare stand. Zodra
|
||||
net10.0-ios in deze lijst staat, wil elke build - ook een build van alleen de APK - de
|
||||
iOS-werkbelasting en een Mac hebben. Op een machine zonder die twee struikelt dan het
|
||||
maken van de Android-app over iets wat er niets mee te maken heeft.
|
||||
|
||||
Aanzetten doe je per keer:
|
||||
|
||||
dotnet build -p:MetIos=true
|
||||
tools\Apk-maken.ps1 (blijft gewoon Android, hier verandert niets)
|
||||
|
||||
of blijvend, door MetIos in een Directory.Build.props of in de omgeving te zetten op de
|
||||
machine die wél aan een Mac hangt. Zie docs/iOS-app.md. -->
|
||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(MetIos)' == 'true'">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>MijnBesluit.App</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<ApplicationTitle>MijnBesluit</ApplicationTitle>
|
||||
<ApplicationId>nl.mijnbesluit.app</ApplicationId>
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
|
||||
|
||||
<!-- Op papier kan MAUI vanaf iOS 12.2, maar onder 13 zijn er meldingen van apps die op een
|
||||
echt toestel omvallen, en 15.0 is wat het sjabloon zelf gebruikt. Dat draait op een
|
||||
iPhone 6s en nieuwer - toestellen uit 2015. Lager gaan levert je een handvol toestellen
|
||||
op en een soort fouten dat je niet kunt nabootsen. -->
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Het echte logo. Het pictogram is alleen het witte woordmerk op een doorzichtige
|
||||
achtergrond, met het logogroen eronder: zo maakt Android er zelf een rond, vierkant of
|
||||
druppelvormig pictogram van zonder dat er letters afvallen. Het woordmerk staat daarom
|
||||
op zeventig procent - alles buiten die cirkel kan een toestel wegsnijden.
|
||||
Het startscherm laat het hele logo zien op de rustige achtergrondkleur. -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.png" Color="#C1D13F" />
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.png" Color="#faf7f2" BaseSize="128,128" />
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Sinds .NET 8 brengt <UseMaui>true</UseMaui> deze packages niet meer vanzelf mee.
|
||||
$(MauiVersion) volgt de werkbelasting die op deze machine staat, dus hier hoeft geen
|
||||
versienummer met de hand bijgehouden te worden. -->
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
|
||||
|
||||
<ProjectReference Include="..\MijnBesluit.AppUi\MijnBesluit.AppUi.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Meldingen op de telefoon, via Firebase.
|
||||
|
||||
Dit blok doet niets zolang google-services.json er niet staat, en gaat vanzelf aan zodra
|
||||
je hem uit de Firebase-console in Platforms\Android zet. Zo is er geen stap "en vergeet
|
||||
het csproj niet aan te passen" die je een keer overslaat, en blijft het project bouwen op
|
||||
een machine waar dat bestand niet staat.
|
||||
|
||||
De schakelaar FIREBASE hoort bij de #if in Toestelmeldingen.cs: zonder het package is die
|
||||
code niet te compileren, dus die staat uit tot het package er is.
|
||||
|
||||
Zie docs/Meldingen.md. -->
|
||||
<PropertyGroup Condition="Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
|
||||
<DefineConstants>$(DefineConstants);FIREBASE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND Exists('$(MSBuildProjectDirectory)\Platforms\Android\google-services.json')">
|
||||
<!-- Binding op com.google.firebase:firebase-messaging. Het versienummer moet passen bij de
|
||||
werkbelasting op deze machine; 125.1.1.1 hoort bij net10.0-android. Klaagt de build over
|
||||
versies, kijk dan in NuGet welke bij jouw .NET-versie hoort. -->
|
||||
<PackageReference Include="Xamarin.Firebase.Messaging" Version="125.1.1.1" />
|
||||
|
||||
<!-- Firebase's afhankelijkheden pinnen oudere AndroidX .Ktx-packages, terwijl MAUI de
|
||||
bijbehorende basispackages al nieuwer binnenhaalt. Dat gaf niet alleen de NU1608-
|
||||
waarschuwingen, maar ook een harde fout bij het dexen: sinds AndroidX Fragment 1.9.0
|
||||
zitten de Kotlin-uitbreidingen in het hoofdpackage zelf, dus kwam bijvoorbeeld
|
||||
androidx.fragment.app.FragmentManagerKt twee keer binnen: een keer uit fragment.aar
|
||||
en een keer uit het oude fragment-ktx.aar.
|
||||
|
||||
Door deze vijf expliciet op de versie te zetten die bij het al opgeloste basispackage
|
||||
hoort, zijn de .Ktx-packages leeg (ze verwijzen alleen nog naar het hoofdpackage) en
|
||||
is de dubbele klasse weg. Verhoogt een nieuwere MAUI de basispackages, dan komen de
|
||||
NU1608-waarschuwingen terug; dat is het signaal om deze nummers mee te verhogen, niet
|
||||
om het blok weg te halen. De nummers komen uit NuGet: Fragment.Ktx 1.9.0 bestaat,
|
||||
Collection.Ktx 1.6.0.1, en de drie Lifecycle-packages op 2.11.0.1. -->
|
||||
<PackageReference Include="Xamarin.AndroidX.Fragment.Ktx" Version="1.9.0" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Collection.Ktx" Version="1.6.0.1" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData" Version="2.11.0.1" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Lifecycle.LiveData.Core.Ktx" Version="2.11.0.1" />
|
||||
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
||||
<GoogleServicesJson Include="Platforms\Android\google-services.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true"
|
||||
android:icon="@mipmap/appicon"
|
||||
android:roundIcon="@mipmap/appicon_round"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- Op welk kanaal een melding terechtkomt die binnenkomt terwijl de app dicht is. Zonder
|
||||
deze regel maakt Firebase daar zelf een kanaal "Overig" voor aan, en staat dezelfde
|
||||
soort melding dus op twee plekken in de instellingen van de telefoon. De naam hoort
|
||||
gelijk te zijn aan Meldingsdienst.Kanaal. -->
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="mijnbesluit-berichten" />
|
||||
</application>
|
||||
|
||||
<!-- Om de app weer te openen nadat hij zichzelf heeft vergrendeld. Dit geeft geen toegang
|
||||
tot vingerafdrukken: de app krijgt alleen ja of nee terug van het toestel. -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<!-- De app praat met de SocketServer. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Camera en galerij, voor foto's bij een kluisitem en video's bij een moment.
|
||||
Hier stond eerder bewust niets: hoe minder rechten, hoe minder er mis kan gaan. Maar
|
||||
zonder CAMERA meldt Android dat het toestel niet kan opnemen, en verdwijnt de knop
|
||||
"Video opnemen" uit de app zonder dat er iets misgaat of iets over te zien is.
|
||||
|
||||
Wat er NIET bij staat en ook niet bij hoort: locatie, contacten, en het schrijven naar
|
||||
de opslag van de telefoon. We lezen alleen wat de gebruiker zelf aanwijst. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<!-- Voor Android 12 en ouder; vanaf 13 doen de twee regels hierboven het werk. -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<!-- Niet verplicht: een toestel zonder camera mag de app gewoon installeren, dan valt
|
||||
alleen de opnameknop weg. -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,14 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
|
||||
LaunchMode = LaunchMode.SingleTop,
|
||||
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
|
||||
ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
|
||||
ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Android.App;
|
||||
using Android.Runtime;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
[Application]
|
||||
public class MainApplication : MauiApplication
|
||||
{
|
||||
public MainApplication(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { }
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
|
||||
public override void OnCreate()
|
||||
{
|
||||
base.OnCreate();
|
||||
|
||||
#if FIREBASE
|
||||
// Het meldingenkanaal moet er zijn voordat de eerste melding binnenkomt, ook als die
|
||||
// binnenkomt terwijl de app dicht is. Hier en niet in de dienst zelf, want dit is het
|
||||
// eerste wat draait.
|
||||
Meldingsdienst.MaakKanaal(this);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
#if FIREBASE
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using AndroidX.Core.App;
|
||||
using Firebase.Messaging;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Vangt meldingen op die binnenkomen terwijl de app openstaat.
|
||||
///
|
||||
/// Android zet een melding alleen zelf in de balk als de app op de achtergrond staat. Staat hij
|
||||
/// open, dan geeft Android hem door aan deze dienst en gebeurt er verder niets - tenzij we hem
|
||||
/// hier zelf tonen. Dat was precies het geval waarin de proefmelding "verstuurd" heette en er
|
||||
/// op de telefoon niets te zien was.
|
||||
///
|
||||
/// De tekst komt uit het data-deel van het bericht en niet uit het notification-deel. Beide
|
||||
/// staan erin (zie FirebasePush), maar het data-deel is het stuk waar de naamgeving in de
|
||||
/// Android-binding niet per versie verschilt.
|
||||
/// </summary>
|
||||
[Service(Exported = false)]
|
||||
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
|
||||
public class Meldingsdienst : FirebaseMessagingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Het kanaal waarop deze meldingen binnenkomen. Vanaf Android 8 hoort elke melding bij een
|
||||
/// kanaal, en kan de gebruiker per kanaal zeggen hoe opdringerig het mag zijn. De naam staat
|
||||
/// ook in AndroidManifest.xml, zodat meldingen die binnenkomen terwijl de app dicht is op
|
||||
/// hetzelfde kanaal terechtkomen en niet onder "Overig".
|
||||
/// </summary>
|
||||
public const string Kanaal = "mijnbesluit-berichten";
|
||||
|
||||
/// <summary>
|
||||
/// Maakt het kanaal aan als het er nog niet is. Mag zo vaak aangeroepen worden als je wilt;
|
||||
/// Android werkt een bestaand kanaal dan alleen bij. Gebeurt bij het starten van de app, want
|
||||
/// een melding die binnenkomt terwijl de app dicht is heeft het kanaal al nodig.
|
||||
/// </summary>
|
||||
public static void MaakKanaal(Context context)
|
||||
{
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;
|
||||
|
||||
var kanaal = new NotificationChannel(Kanaal, "Berichten", NotificationImportance.High)
|
||||
{
|
||||
Description = "Laat weten dat er iets voor je klaarstaat. Wat erin staat komt er nooit bij te staan."
|
||||
};
|
||||
|
||||
var beheer = (NotificationManager?)context.GetSystemService(Context.NotificationService);
|
||||
beheer?.CreateNotificationChannel(kanaal);
|
||||
}
|
||||
|
||||
public override void OnMessageReceived(RemoteMessage bericht)
|
||||
{
|
||||
var gegevens = bericht.Data;
|
||||
|
||||
gegevens.TryGetValue("titel", out var titel);
|
||||
gegevens.TryGetValue("tekst", out var tekst);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(titel)) titel = "MijnBesluit";
|
||||
if (string.IsNullOrWhiteSpace(tekst)) tekst = "Er staat iets voor je klaar.";
|
||||
|
||||
Toon(this, titel, tekst);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Firebase geeft een toestel af en toe een nieuw kenmerk. Het oude werkt dan niet meer.
|
||||
/// De app meldt zich bij elke aanmelding opnieuw aan met het kenmerk van dat moment, dus dat
|
||||
/// herstelt zichzelf zodra je weer inlogt. Zolang je ingelogd blijft kan er een melding
|
||||
/// tussendoor vallen; dat is bewust niet hier opgelost, want hier is geen verbinding met de
|
||||
/// server en geen sleutel om mee te praten.
|
||||
/// </summary>
|
||||
public override void OnNewToken(string kenmerk) => base.OnNewToken(kenmerk);
|
||||
|
||||
private static void Toon(Context context, string titel, string tekst)
|
||||
{
|
||||
MaakKanaal(context);
|
||||
|
||||
// Aantikken opent de app. SingleTop, zodat je niet een tweede keer hetzelfde scherm
|
||||
// opent bovenop het scherm waar je al was.
|
||||
var openen = new Intent(context, typeof(MainActivity));
|
||||
openen.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
|
||||
|
||||
var tik = PendingIntent.GetActivity(context, 0, openen,
|
||||
PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable);
|
||||
|
||||
// ApplicationInfo.Icon en niet een eigen tekening: dat is een nummer dat Android zelf
|
||||
// bijhoudt, dus hier hoeft geen tweede plaatje bijgehouden te worden dat kan ontbreken.
|
||||
var melding = new NotificationCompat.Builder(context, Kanaal)
|
||||
.SetContentTitle(titel)
|
||||
.SetContentText(tekst)
|
||||
.SetStyle(new NotificationCompat.BigTextStyle().BigText(tekst))
|
||||
.SetSmallIcon(context.ApplicationInfo!.Icon)
|
||||
.SetAutoCancel(true)
|
||||
.SetPriority((int)NotificationPriority.High)
|
||||
.SetContentIntent(tik)
|
||||
.Build();
|
||||
|
||||
// Elk bericht een eigen nummer, anders vervangt de tweede melding de eerste.
|
||||
NotificationManagerCompat.From(context).Notify(Volgnummer(), melding);
|
||||
}
|
||||
|
||||
private static int _laatste;
|
||||
private static int Volgnummer() => Interlocked.Increment(ref _laatste);
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "1047194010071",
|
||||
"project_id": "mijnbesluit-f04b5",
|
||||
"storage_bucket": "mijnbesluit-f04b5.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1047194010071:android:815622efe5c7b59a084b92",
|
||||
"android_client_info": {
|
||||
"package_name": "nl.mijnbesluit.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDPDKCc6tQm8Juh9LwvvWlxmUFTrMCU4So"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Foundation;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// De iOS-kant van het opstarten. Tegenhanger van MainApplication op Android: hij doet niets
|
||||
/// meer dan dezelfde MauiProgram aanroepen, want alles wat de app ís staat in gedeelde code.
|
||||
/// </summary>
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<!-- 1 = iPhone, 2 = iPad. De schermen zijn gemaakt voor een telefoon, maar een iPad
|
||||
weigeren levert alleen een gebruiker op die het niet snapt. -->
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
|
||||
<!-- ==========================================================================
|
||||
Waarvoor de app iets van het toestel vraagt.
|
||||
|
||||
Deze teksten zijn geen formaliteit. iOS laat ze letterlijk zien op het moment
|
||||
dat de app het vraagt, en als de tekst ontbreekt sluit iOS de app ter plekke
|
||||
af - geen foutmelding, hij is gewoon weg. Dat is de klassieke "hij doet het
|
||||
op Android wel"-val.
|
||||
|
||||
Ze staan in het Nederlands omdat de app Nederlands is; iemand die hier "ja"
|
||||
op moet zeggen, hoort te kunnen lezen waarvoor.
|
||||
========================================================================== -->
|
||||
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Om MijnBesluit te openen zonder telkens een code uit je mail te halen. Je gezicht wordt door je telefoon gecontroleerd; MijnBesluit krijgt het nooit te zien.</string>
|
||||
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Om een foto of video te maken die je in je kluis bewaart of als bericht meestuurt.</string>
|
||||
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Om het geluid op te nemen bij een videobericht.</string>
|
||||
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Om een foto of document uit je fotobibliotheek toe te voegen aan je kluis of aan een bericht.</string>
|
||||
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Om een foto uit MijnBesluit op te slaan in je fotobibliotheek.</string>
|
||||
|
||||
<!-- ==========================================================================
|
||||
BEWUST NOG NIET INGEVULD: ITSAppUsesNonExemptEncryption.
|
||||
|
||||
Apple vraagt bij elke inzending of de app encryptie gebruikt die buiten de
|
||||
vrijstellingen valt. Zet je hier `false`, dan verklaar je dat dat niet zo is -
|
||||
en dat is een verklaring met juridisch gewicht, geen vinkje. Deze app
|
||||
versleutelt een kluis met gegevens, dus dat antwoord is niet vanzelfsprekend
|
||||
"nee" zoals bij een app die alleen HTTPS gebruikt.
|
||||
|
||||
Laat dit staan zoals het is: zonder deze sleutel vraagt App Store Connect het
|
||||
bij elke inzending, en dan beantwoord je het bewust. Wil je er vanaf, laat het
|
||||
dan eerst nakijken door iemand die deze regels kent, en zet het antwoord er
|
||||
daarna bij met de datum en de reden erbij in dit bestand.
|
||||
|
||||
Wat hier NIET hoort: een uitzondering op App Transport Security. De verbinding
|
||||
met de SocketServer is een eigen TLS-socket en geen URL-verkeer, dus ATS gaat
|
||||
er niet over. Een uitzondering toevoegen die je niet nodig hebt, levert alleen
|
||||
vragen op bij de beoordeling.
|
||||
========================================================================== -->
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>Het startpunt op iOS. Android heeft dit niet nodig; daar start Android de Activity.</summary>
|
||||
public class Program
|
||||
{
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
// Vanaf hier neemt UIKit het over en roept het AppDelegate aan.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,36 @@
|
||||
using MijnBesluit.Client;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Bewaart het sessietoken in de beveiligde opslag van het toestel: op Android is dat de
|
||||
/// Keystore, op iOS de Keychain. Dus niet in een gewoon bestand, want daar kan een andere
|
||||
/// app of iemand met het toestel in handen bij.
|
||||
/// </summary>
|
||||
public class ToestelTokenopslag : ITokenopslag
|
||||
{
|
||||
private const string Sleutel = "mijnbesluit-sessie";
|
||||
|
||||
public async Task<string?> LeesAsync()
|
||||
{
|
||||
try { return await SecureStorage.Default.GetAsync(Sleutel); }
|
||||
catch
|
||||
{
|
||||
// Op sommige toestellen is de beveiligde opslag stuk na een herstel uit back-up.
|
||||
// Dan is er gewoon geen sessie en logt de gebruiker opnieuw in.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SchrijfAsync(string token)
|
||||
{
|
||||
try { await SecureStorage.Default.SetAsync(Sleutel, token); }
|
||||
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
|
||||
}
|
||||
|
||||
public Task WisAsync()
|
||||
{
|
||||
SecureStorage.Default.Remove(Sleutel);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.Maui.Graphics;
|
||||
using Microsoft.Maui.Graphics.Platform;
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Het kiezen en maken van bestanden op een Android- of iOS-toestel, met de kiezers die MAUI
|
||||
/// daarvoor meelevert. De schermen zien hier niets van; die kennen alleen
|
||||
/// <see cref="IBestandskiezer"/>.
|
||||
///
|
||||
/// Foto's worden hier verkleind voordat ze de deur uit gaan. Een toestelcamera maakt tegenwoordig
|
||||
/// bestanden van tien megabyte of meer, en die gaan in stukken van een halve megabyte over de
|
||||
/// lijn - op een mobiele verbinding duurt dat te lang voor iets waar je alleen maar naar kijkt.
|
||||
/// Naar 1600 pixels terugbrengen scheelt een factor tien tot twintig en is op een telefoon niet
|
||||
/// te zien. Video's blijven zoals ze zijn: opnieuw coderen kost meer tijd dan het oplevert.
|
||||
/// </summary>
|
||||
public class Toestelbestanden : IBestandskiezer
|
||||
{
|
||||
/// <summary>De langste zijde van een foto die wordt verstuurd.</summary>
|
||||
public const int MaxZijde = 1600;
|
||||
|
||||
/// <summary>Hoe hard er op de foto mag worden ingeleverd. 0.8 is ruim voldoende om te tonen.</summary>
|
||||
private const float Kwaliteit = 0.8f;
|
||||
|
||||
/// <summary>Onder deze grootte is verkleinen de moeite niet waard.</summary>
|
||||
private const int VerkleinVanaf = 400 * 1024;
|
||||
|
||||
public bool KanCamera => MediaPicker.Default.IsCaptureSupported;
|
||||
|
||||
public async Task<GekozenBestand?> KiesDocumentAsync()
|
||||
{
|
||||
var gekozen = await FilePicker.Default.PickAsync(new PickOptions
|
||||
{
|
||||
PickerTitle = "Kies een document"
|
||||
});
|
||||
|
||||
return await LeesAsync(gekozen, "application/octet-stream");
|
||||
}
|
||||
|
||||
public async Task<GekozenBestand?> MaakFotoAsync()
|
||||
{
|
||||
if (!MediaPicker.Default.IsCaptureSupported) return null;
|
||||
|
||||
// De camera geeft een naam van tweeëndertig tekens zonder betekenis. Die staat straks in
|
||||
// je kluis en zegt je niets; het moment waarop je de foto maakte wel.
|
||||
return await LeesAsync(await MediaPicker.Default.CapturePhotoAsync(), "image/jpeg",
|
||||
Eigennaam("Foto", ".jpg"));
|
||||
}
|
||||
|
||||
public async Task<GekozenBestand?> KiesFotoAsync()
|
||||
{
|
||||
// PickPhotosAsync in plaats van PickPhotoAsync: die laatste is vervallen. We nemen de
|
||||
// eerste foto; meerdere tegelijk kiezen komt later als het nodig blijkt.
|
||||
var gekozen = await MediaPicker.Default.PickPhotosAsync();
|
||||
return await LeesAsync(gekozen?.FirstOrDefault(), "image/jpeg");
|
||||
}
|
||||
|
||||
public async Task<GekozenBestand?> NeemVideoOpAsync()
|
||||
{
|
||||
if (!MediaPicker.Default.IsCaptureSupported) return null;
|
||||
return await LeesAsync(await MediaPicker.Default.CaptureVideoAsync(), "video/mp4",
|
||||
Eigennaam("Video", ".mp4"));
|
||||
}
|
||||
|
||||
public async Task<GekozenBestand?> KiesVideoAsync()
|
||||
{
|
||||
// PickVideosAsync in plaats van PickVideoAsync, om dezelfde reden als bij de foto's: die
|
||||
// laatste is vervallen. We nemen de eerste video; meerdere tegelijk kiezen komt later als
|
||||
// het nodig blijkt.
|
||||
var gekozen = await MediaPicker.Default.PickVideosAsync();
|
||||
return await LeesAsync(gekozen?.FirstOrDefault(), "video/mp4");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leest het gekozen bestand helemaal in. Dat kan omdat er een grens op de grootte staat;
|
||||
/// zonder die grens zou een lange video-opname het geheugen van de app opmaken.
|
||||
/// </summary>
|
||||
private static async Task<GekozenBestand?> LeesAsync(FileResult? gekozen, string standaardSoort,
|
||||
string? naam = null)
|
||||
{
|
||||
if (gekozen is null) return null;
|
||||
|
||||
using var stroom = await gekozen.OpenReadAsync();
|
||||
using var buffer = new MemoryStream();
|
||||
await stroom.CopyToAsync(buffer);
|
||||
|
||||
var soort = string.IsNullOrWhiteSpace(gekozen.ContentType) ? standaardSoort : gekozen.ContentType;
|
||||
var inhoud = buffer.ToArray();
|
||||
|
||||
if (soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && inhoud.Length > VerkleinVanaf)
|
||||
inhoud = Verklein(inhoud) ?? inhoud;
|
||||
|
||||
return new GekozenBestand(naam ?? Bruikbaar(gekozen.FileName), soort, inhoud);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Een naam waar je later nog iets aan hebt: "Foto 15-09-2026 14.10". De tijd erbij, want op
|
||||
/// één dag maak je er zo drie en dan wil je ze uit elkaar kunnen houden.
|
||||
/// </summary>
|
||||
private static string Eigennaam(string soort, string extensie) =>
|
||||
// De punt tussen uren en minuten staat tussen enkele aanhalingstekens. Dat is hoe een
|
||||
// opmaakpatroon in .NET zegt "letterlijk dit teken"; een backslash zou hier een
|
||||
// ongeldige ontsnappingsreeks in C# zijn, en een kale punt hoort bij .fff.
|
||||
$"{soort} {DateTime.Now:dd-MM-yyyy HH'.'mm}{extensie}";
|
||||
|
||||
/// <summary>
|
||||
/// Een naam die het toestel zelf aandraagt, tenzij die nergens op slaat. Een galerij geeft
|
||||
/// soms alleen een lange reeks tekens terug; daar is een datum beter dan.
|
||||
/// </summary>
|
||||
private static string Bruikbaar(string? naam)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(naam)) return Eigennaam("Bestand", "");
|
||||
|
||||
var kaal = Path.GetFileNameWithoutExtension(naam);
|
||||
var extensie = Path.GetExtension(naam);
|
||||
|
||||
// Twintig tekens of meer, en geen enkele klinker of streepje: dat is geen naam maar een
|
||||
// kenmerk. Denk aan 824c9d46a1b749ab9f92986b.
|
||||
var onleesbaar = kaal.Length >= 20 && kaal.All(char.IsLetterOrDigit)
|
||||
&& !kaal.Any(c => "aeiouAEIOU".Contains(c));
|
||||
|
||||
return onleesbaar ? Eigennaam("Bestand", extensie) : naam;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brengt een foto terug tot <see cref="MaxZijde"/> pixels op de langste zijde.
|
||||
///
|
||||
/// Gaat er iets mis - een bestand dat geen afbeelding blijkt, een formaat dat het toestel
|
||||
/// niet kent - dan geven we null terug en gaat het origineel gewoon de deur uit. Een foto
|
||||
/// die traag verstuurt is vervelend; een foto die helemaal niet aankomt is erger.
|
||||
/// </summary>
|
||||
private static byte[]? Verklein(byte[] inhoud)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bron = new MemoryStream(inhoud);
|
||||
using var afbeelding = PlatformImage.FromStream(bron);
|
||||
|
||||
// Downsize doet niets als de foto al kleiner is; dan zijn we alsnog klaar, want het
|
||||
// opnieuw opslaan levert bij die grootte weinig op.
|
||||
if (afbeelding.Width <= MaxZijde && afbeelding.Height <= MaxZijde) return null;
|
||||
|
||||
using var kleiner = afbeelding.Downsize(MaxZijde, disposeOriginal: false);
|
||||
using var uit = new MemoryStream();
|
||||
kleiner.Save(uit, ImageFormat.Jpeg, Kwaliteit);
|
||||
|
||||
var verkleind = uit.ToArray();
|
||||
|
||||
// Alleen als het echt scheelt. Een enkele keer is het resultaat groter dan het
|
||||
// origineel; dan is het origineel de betere keus.
|
||||
return verkleind.Length > 0 && verkleind.Length < inhoud.Length ? verkleind : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Opent een adres in de browser van de telefoon. Bewust buiten de app: een advertentielink die
|
||||
/// binnen de webview opent maakt van MijnBesluit een browser, en dan staat iemand op de website
|
||||
/// van een adverteerder zonder weg terug naar zijn eigen kluis.
|
||||
/// </summary>
|
||||
public class Toestelbuitenlink : IBuitenlink
|
||||
{
|
||||
public async Task OpenAsync(string adres)
|
||||
{
|
||||
if (!Uri.TryCreate(adres, UriKind.Absolute, out var adresje)) return;
|
||||
|
||||
// Alleen http en https. Een ander schema kan een andere app openen, en dat is niet wat
|
||||
// hier hoort te gebeuren.
|
||||
if (adresje.Scheme != Uri.UriSchemeHttp && adresje.Scheme != Uri.UriSchemeHttps) return;
|
||||
|
||||
try
|
||||
{
|
||||
await Browser.Default.OpenAsync(adresje, BrowserLaunchMode.SystemPreferred);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Geen browser op dit toestel, of de gebruiker brak het af. Dat is geen reden om de
|
||||
// app te laten vallen.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Instellingen die alleen op dit toestel gelden, in de gewone voorkeursopslag van Android.
|
||||
///
|
||||
/// Bewust niet in de Keystore: daar hoort alleen in wat geheim is. Hoe lang het duurt voordat
|
||||
/// de app zichzelf uitlogt is geen geheim, en de Keystore is traag en kan stuk na een herstel
|
||||
/// uit back-up.
|
||||
/// </summary>
|
||||
public class Toestelinstellingen : IAppinstellingen
|
||||
{
|
||||
public int Lees(string sleutel, int standaard)
|
||||
{
|
||||
try { return Preferences.Default.Get(sleutel, standaard); }
|
||||
catch { return standaard; }
|
||||
}
|
||||
|
||||
public void Schrijf(string sleutel, int waarde)
|
||||
{
|
||||
try { Preferences.Default.Set(sleutel, waarde); }
|
||||
catch { /* Niet kunnen onthouden is vervelend, maar geen reden om de app te laten vallen. */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
#if ANDROID && FIREBASE
|
||||
// AsAsync() maakt van een Task van Google Play Services een gewone await-bare Task. Het is een
|
||||
// uitbreidingsmethode, dus zonder deze using bestaat hij niet en zegt de compiler alleen dat
|
||||
// "Task" hem niet kent. Staat achter dezelfde schakelaar als de code die hem gebruikt: zonder
|
||||
// het Firebase-package bestaat deze naamruimte niet.
|
||||
using Android.Gms.Extensions;
|
||||
#endif
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Haalt het Firebase-kenmerk van dit toestel op.
|
||||
///
|
||||
/// Let op: de regels hieronder met <c>#if ANDROID</c> werken pas als het package
|
||||
/// <c>Xamarin.Firebase.Messaging</c> in MijnBesluit.App zit én <c>google-services.json</c>
|
||||
/// in Platforms\Android staat. Zolang dat er niet is, geeft deze klasse null terug en werkt
|
||||
/// de app gewoon - alleen zonder meldingen. Zie docs/Meldingen.md.
|
||||
///
|
||||
/// Op iOS is dat nu ook zo: er komt geen kenmerk en dus geen melding, en de rest van de app
|
||||
/// werkt gewoon. Dat is met opzet geen half werkende poging. Meldingen op iOS lopen via APNs,
|
||||
/// en daarvoor is meer nodig dan code: een APNs-sleutel uit het Apple Developer-account, de
|
||||
/// Push Notifications-capability, een provisioning profile en een Entitlements.plist. Dat hangt
|
||||
/// aan het account en niet aan dit bestand, dus het komt erbij zodra dat account er is.
|
||||
/// Zie docs/iOS-app.md.
|
||||
/// </summary>
|
||||
public class Toestelmeldingen : IMeldingen
|
||||
{
|
||||
public string Platform =>
|
||||
#if IOS
|
||||
"ios";
|
||||
#else
|
||||
"android";
|
||||
#endif
|
||||
|
||||
public string? Toestelnaam => DeviceInfo.Current.Name;
|
||||
|
||||
public async Task<string?> KenmerkAsync()
|
||||
{
|
||||
// Android 13 en hoger vraagt apart toestemming voor meldingen. Zonder die toestemming
|
||||
// komt er wel een kenmerk, maar ziet de gebruiker nooit iets.
|
||||
if (!await ToestemmingAsync()) return null;
|
||||
|
||||
#if ANDROID && FIREBASE
|
||||
try
|
||||
{
|
||||
// GetToken staat in deze versie van de binding als verouderd gemarkeerd, maar het is
|
||||
// wel de methode die werkt en waar Google zelf naar verwijst. Zodra er een opvolger
|
||||
// is die het kenmerk teruggeeft, hoort die hier te komen.
|
||||
var taak = Firebase.Messaging.FirebaseMessaging.Instance.GetToken();
|
||||
var kenmerk = await taak.AsAsync<Java.Lang.String>();
|
||||
return kenmerk?.ToString();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Geen Google Play-diensten op dit toestel, of Firebase niet ingericht.
|
||||
return null;
|
||||
}
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
private static async Task<bool> ToestemmingAsync()
|
||||
{
|
||||
#if ANDROID
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return true;
|
||||
|
||||
var stand = await Permissions.CheckStatusAsync<Permissions.PostNotifications>();
|
||||
if (stand != PermissionStatus.Granted)
|
||||
stand = await Permissions.RequestAsync<Permissions.PostNotifications>();
|
||||
|
||||
return stand == PermissionStatus.Granted;
|
||||
#else
|
||||
return await Task.FromResult(true);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using MijnBesluit.AppUi;
|
||||
|
||||
#if ANDROID
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Hardware.Biometrics;
|
||||
using Android.OS;
|
||||
#elif IOS
|
||||
using LocalAuthentication;
|
||||
#endif
|
||||
|
||||
namespace MijnBesluit.App;
|
||||
|
||||
/// <summary>
|
||||
/// Vraagt het toestel om iemand te controleren: vingerafdruk, gezicht, of de pincode of het
|
||||
/// patroon van de telefoon zelf.
|
||||
///
|
||||
/// Wat hier NIET gebeurt: de app vraagt geen eigen pincode en bewaart er ook geen. Er is dus
|
||||
/// niets bij te houden, niets te vergeten en niets te lekken. De telefoon doet de controle in
|
||||
/// een apart stuk hardware waar geen enkele app bij kan, en geeft alleen ja of nee terug.
|
||||
///
|
||||
/// Vanaf Android 10. Daarvoor bestaat dit venster nog niet in de vorm waarin het ook de code
|
||||
/// van de telefoon accepteert, en dan zou een vingerafdrukloze telefoon er niet meer in komen.
|
||||
/// Op oudere toestellen is <see cref="Beschikbaar"/> dus onwaar en logt de app uit zoals eerst.
|
||||
///
|
||||
/// Op iOS doet LocalAuthentication hetzelfde, met dezelfde afspraak: Face ID of Touch ID, en
|
||||
/// de toestelcode als terugval. Dat laatste zit daar in de keuze van het beleid
|
||||
/// (DeviceOwnerAuthentication) in plaats van in een aparte schakelaar.
|
||||
/// </summary>
|
||||
public class Toestelslot : IToestelslot
|
||||
{
|
||||
#if ANDROID
|
||||
public bool Beschikbaar
|
||||
{
|
||||
get
|
||||
{
|
||||
// Zonder schermslot heeft vergrendelen geen zin: dan komt iedereen die de telefoon
|
||||
// in handen heeft er met één veeg weer in, en is uitloggen de enige echte grendel.
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(29)) return false;
|
||||
|
||||
var bewaking = (KeyguardManager?)Platform.AppContext.GetSystemService(Context.KeyguardService);
|
||||
return bewaking?.IsDeviceSecure == true;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> OntgrendelAsync(string titel, string uitleg)
|
||||
{
|
||||
if (!Beschikbaar) return Task.FromResult(false);
|
||||
|
||||
var activiteit = Platform.CurrentActivity;
|
||||
if (activiteit is null) return Task.FromResult(false);
|
||||
|
||||
var klaar = new TaskCompletionSource<bool>();
|
||||
|
||||
try
|
||||
{
|
||||
// SetDeviceCredentialAllowed is vanaf Android 11 als verouderd gemarkeerd, maar het
|
||||
// is de enige manier die vanaf Android 10 werkt én de code van de telefoon als
|
||||
// terugval toelaat. De opvolger (SetAllowedAuthenticators) bestaat pas vanaf 11.
|
||||
// Zolang de app vanaf 10 werkt, hoort dit hier te staan.
|
||||
var venster = new BiometricPrompt.Builder(activiteit)
|
||||
.SetTitle(titel)
|
||||
.SetDescription(uitleg)
|
||||
.SetDeviceCredentialAllowed(true)
|
||||
.Build();
|
||||
|
||||
// Geen annuleersein dat we zelf afbreken: het venster heeft zijn eigen knop, en de
|
||||
// uitkomst daarvan komt via OnAuthenticationError binnen.
|
||||
venster.Authenticate(new CancellationSignal(), activiteit.MainExecutor!, new Antwoord(klaar));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Een toestel dat hier struikelt hoort de app niet te laten vallen. Het slot blijft
|
||||
// dan dicht en je kunt nog altijd helemaal uitloggen.
|
||||
klaar.TrySetResult(false);
|
||||
}
|
||||
|
||||
return klaar.Task;
|
||||
}
|
||||
|
||||
/// <summary>Wat het venster terugmeldt.</summary>
|
||||
private sealed class Antwoord : BiometricPrompt.AuthenticationCallback
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _klaar;
|
||||
|
||||
public Antwoord(TaskCompletionSource<bool> klaar) => _klaar = klaar;
|
||||
|
||||
public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult? uitkomst) =>
|
||||
_klaar.TrySetResult(true);
|
||||
|
||||
/// <summary>Afgebroken, te vaak mis, of het toestel kan het niet. Hoe dan ook: niet open.</summary>
|
||||
public override void OnAuthenticationError(BiometricErrorCode code, Java.Lang.ICharSequence? tekst) =>
|
||||
_klaar.TrySetResult(false);
|
||||
|
||||
/// <summary>
|
||||
/// Eén poging die niet klopte - een vinger die verkeerd lag. Het venster blijft staan en
|
||||
/// laat het opnieuw proberen, dus hier hoort niets te gebeuren. Pas als het toestel er
|
||||
/// mee ophoudt komt OnAuthenticationError.
|
||||
/// </summary>
|
||||
public override void OnAuthenticationFailed() { }
|
||||
}
|
||||
#elif IOS
|
||||
public bool Beschikbaar
|
||||
{
|
||||
get
|
||||
{
|
||||
// DeviceOwnerAuthentication en niet DeviceOwnerAuthenticationWithBiometrics: het
|
||||
// eerste is "Face ID of Touch ID, en anders de code van de telefoon", het tweede
|
||||
// eist een vingerafdruk of gezicht en niets anders. Dat laatste zou betekenen dat
|
||||
// een toestel met alleen een cijfercode er niet meer in komt - precies de gebruiker
|
||||
// die we op Android ook niet buitensluiten.
|
||||
using var vraag = new LAContext();
|
||||
return vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> OntgrendelAsync(string titel, string uitleg)
|
||||
{
|
||||
// iOS toont één regel tekst en geen kop, dus de titel valt hier weg. Wat er in beeld
|
||||
// komt is de uitleg; die hoort dus op zichzelf te kunnen staan.
|
||||
//
|
||||
// Let op: staat NSFaceIDUsageDescription niet in Info.plist, dan sluit iOS de app hier
|
||||
// ter plekke af - geen foutmelding, hij is weg. Zie Platforms/iOS/Info.plist; er is een
|
||||
// zelfcontrole die daarop let.
|
||||
using var vraag = new LAContext();
|
||||
|
||||
if (!vraag.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthentication, out _)) return false;
|
||||
|
||||
// Afgebroken, te vaak mis, of het toestel kan het niet: dan komt hier onwaar uit en
|
||||
// blijft het slot dicht. De fout zelf laten we liggen - er valt voor de gebruiker niets
|
||||
// mee te doen, en op het slotscherm staat al wat hij nog wél kan.
|
||||
var (gelukt, _) = await vraag.EvaluatePolicyAsync(LAPolicy.DeviceOwnerAuthentication, uitleg);
|
||||
return gelukt;
|
||||
}
|
||||
|
||||
#else
|
||||
public bool Beschikbaar => false;
|
||||
|
||||
public Task<bool> OntgrendelAsync(string titel, string uitleg) => Task.FromResult(false);
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>MijnBesluit</title>
|
||||
|
||||
<!-- Dezelfde opmaak als de website; het bestand komt uit MijnBesluit.AppUi. -->
|
||||
<link rel="stylesheet" href="_content/MijnBesluit.AppUi/app.css" />
|
||||
<style>
|
||||
/* Ruimte voor de statusbalk en de knoppen onderaan de telefoon. */
|
||||
body { padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||
env(safe-area-inset-bottom) env(safe-area-inset-left); }
|
||||
|
||||
#blazor-error-ui {
|
||||
display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||
background: #fbf1e3; color: #8a5a22; padding: .8rem 1rem;
|
||||
font: 15px "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div style="padding:2rem;text-align:center;color:#6b645c;font:17px 'Segoe UI',system-ui,sans-serif">
|
||||
Even geduld…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
Er ging iets mis. <a href="" class="reload">Opnieuw laden</a>
|
||||
</div>
|
||||
|
||||
<script src="_content/MijnBesluit.AppUi/validatie.js"></script>
|
||||
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user