MAUI Blazor 許可權經驗分享 (定位,使用相機)

2022-08-05 06:00:28

入門文章

Blazor Hybrid / MAUI 簡介和實戰
https://www.cnblogs.com/densen2014/p/16240966.html

在 Mac 上開發 .NET MAUI
https://www.cnblogs.com/densen2014/p/16057571.html

在 Windows 上開發 .NET MAUI
https://docs.microsoft.com/zh-cn/dotnet/maui/get-started/installation

之前的工程已經能正常使用blazor的webview下獲取定位,使用相機等功能,新版釋出後反而許可權獲取不到了,定位頁面出現如下錯誤

由於這個問題主要出現在安卓系統,下面只選了安卓的步驟分享

Android

應用所需的許可權和功能在 AndroidManifest.xml 中定義。請參閱 官方檔案 瞭解 Android App Manifest。

某些 Android 裝置許可權需要在執行時顯示提示,以便使用者可以授予或拒絕該許可權。 Android 有一個推薦的 workflow 用於在執行時請求許可權,此工作流必須由應用手動實現。 WebView 的 WebChromeClient 負責對許可權請求做出反應,因此該專案提供了一個 PermissionManagingBlazorWebChromeClient 將 Webkit 資源對映到 Android 許可權並執行推薦的許可權請求工作流。

在向 AndroidManifest.xml 新增其他許可權後,請務必更新 PermissionManagingBlazorWebChromeClient.cs 以包含該許可權的「基本原理字串」,解釋應用程式需要它的原因。可能還需要在 許可權請求型別 和 Android Manifest 許可權之間定義其他對映。

1. 應用所需的許可權Platforms/Android/AndroidManifest.xml

以下是我所有的測試許可權列表,各位看官按需自由組合.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="31" />
  <application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
  <uses-feature android:name="android.hardware.camera" />
  <uses-feature android:name="android.hardware.camera.autofocus" />
  
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
  <uses-feature android:name="android.hardware.location" android:required="false" />
  <uses-feature android:name="android.hardware.location.gps" android:required="false" />
  <uses-feature android:name="android.hardware.location.network" android:required="false" />

  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
  <uses-permission android:name="android.permission.CALL_PHONE" />
  <uses-permission android:name="android.permission.CAMERA" />
  <uses-permission android:name="android.permission.ModifyAudioSettings" />
  <uses-permission android:name="android.permission.FLASHLIGHT" />
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
  <uses-permission android:name="android.permission.USE_FINGERPRINT" />
  <uses-permission android:name="android.permission.VIBRATE" />
  <uses-permission android:name="android.permission.WAKE_LOCK" />
  <uses-permission android:name="android.permission.WRITE_SETTINGS" />
  <uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
  <uses-permission android:name="android.permission.CaptureSecureVideoOutput" />
  <uses-permission android:name="android.permission.CaptureVideoOutput" />
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  <uses-permission android:name="android.permission.BATTERY_STATS" />
  <queries>
    <intent>
      <action android:name="android.media.action.IMAGE_CAPTURE" />
    </intent>
  </queries>
</manifest>

2. 新增檔案 Platforms/Android/PermissionManagingBlazorWebChromeClient.cs

using Android;
using Android.App;
using Android.Content.PM;
using Android.Graphics;
using Android.OS;
using Android.Views;
using Android.Webkit;
using AndroidX.Activity;
using AndroidX.Activity.Result;
using AndroidX.Activity.Result.Contract;
using AndroidX.Core.Content;
using Java.Interop;
using System;
using System.Collections.Generic;
using View = Android.Views.View;
using WebView = Android.Webkit.WebView;

namespace BlazorMaui;

internal class PermissionManagingBlazorWebChromeClient : WebChromeClient, IActivityResultCallback
{
    // This class implements a permission requesting workflow that matches workflow recommended
    // by the official Android developer documentation.
    // See: https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions
    // The current implementation supports location, camera, and microphone permissions. To add your own,
    // update the s_rationalesByPermission dictionary to include your rationale for requiring the permission.
    // If necessary, you may need to also update s_requiredPermissionsByWebkitResource to define how a specific
    // Webkit resource maps to an Android permission.

    // In a real app, you would probably use more convincing rationales tailored toward what your app does.
    private const string CameraAccessRationale = "This app requires access to your camera. Please grant access to your camera when requested.";
    private const string LocationAccessRationale = "This app requires access to your location. Please grant access to your precise location when requested.";
    private const string MicrophoneAccessRationale = "This app requires access to your microphone. Please grant access to your microphone when requested.";

    private static readonly Dictionary<string, string> s_rationalesByPermission = new()
    {
        [Manifest.Permission.Camera] = CameraAccessRationale,
        [Manifest.Permission.AccessFineLocation] = LocationAccessRationale,
        [Manifest.Permission.RecordAudio] = MicrophoneAccessRationale,
        // Add more rationales as you add more supported permissions.
    };

    private static readonly Dictionary<string, string[]> s_requiredPermissionsByWebkitResource = new()
    {
        [PermissionRequest.ResourceVideoCapture] = new[] { Manifest.Permission.Camera },
        [PermissionRequest.ResourceAudioCapture] = new[] { Manifest.Permission.ModifyAudioSettings, Manifest.Permission.RecordAudio },
        // Add more Webkit resource -> Android permission mappings as needed.
    };

    private readonly WebChromeClient _blazorWebChromeClient;
    private readonly ComponentActivity _activity;
    private readonly ActivityResultLauncher _requestPermissionLauncher;

    private Action<bool>? _pendingPermissionRequestCallback;

    public PermissionManagingBlazorWebChromeClient(WebChromeClient blazorWebChromeClient, ComponentActivity activity)
    {
        _blazorWebChromeClient = blazorWebChromeClient;
        _activity = activity;
        _requestPermissionLauncher = _activity.RegisterForActivityResult(new ActivityResultContracts.RequestPermission(), this);
    }

    public override void OnCloseWindow(Android.Webkit.WebView? window)
    {
        _blazorWebChromeClient.OnCloseWindow(window);
        _requestPermissionLauncher.Unregister();
    }

    public override void OnGeolocationPermissionsShowPrompt(string? origin, GeolocationPermissions.ICallback? callback)
    {
        ArgumentNullException.ThrowIfNull(callback, nameof(callback));

        RequestPermission(Manifest.Permission.AccessFineLocation, isGranted => callback.Invoke(origin, isGranted, false));
    }

    public override void OnPermissionRequest(PermissionRequest? request)
    {
        ArgumentNullException.ThrowIfNull(request, nameof(request));

        if (request.GetResources() is not { } requestedResources)
        {
            request.Deny();
            return;
        }

        RequestAllResources(requestedResources, grantedResources =>
        {
            if (grantedResources.Count == 0)
            {
                request.Deny();
            }
            else
            {
                request.Grant(grantedResources.ToArray());
            }
        });
    }

    private void RequestAllResources(Memory<string> requestedResources, Action<List<string>> callback)
    {
        if (requestedResources.Length == 0)
        {
            // No resources to request - invoke the callback with an empty list.
            callback(new());
            return;
        }

        var currentResource = requestedResources.Span[0];
        var requiredPermissions = s_requiredPermissionsByWebkitResource.GetValueOrDefault(currentResource, Array.Empty<string>());

        RequestAllPermissions(requiredPermissions, isGranted =>
        {
            // Recurse with the remaining resources. If the first resource was granted, use a modified callback
            // that adds the first resource to the granted resources list.
            RequestAllResources(requestedResources[1..], !isGranted ? callback : grantedResources =>
            {
                grantedResources.Add(currentResource);
                callback(grantedResources);
            });
        });
    }

    private void RequestAllPermissions(Memory<string> requiredPermissions, Action<bool> callback)
    {
        if (requiredPermissions.Length == 0)
        {
            // No permissions left to request - success!
            callback(true);
            return;
        }

        RequestPermission(requiredPermissions.Span[0], isGranted =>
        {
            if (isGranted)
            {
                // Recurse with the remaining permissions.
                RequestAllPermissions(requiredPermissions[1..], callback);
            }
            else
            {
                // The first required permission was not granted. Fail now and don't attempt to grant
                // the remaining permissions.
                callback(false);
            }
        });
    }

    private void RequestPermission(string permission, Action<bool> callback)
    {
        // This method implements the workflow described here:
        // https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions

        if (ContextCompat.CheckSelfPermission(_activity, permission) == Permission.Granted)
        {
            callback.Invoke(true);
        }
        else if (_activity.ShouldShowRequestPermissionRationale(permission) && s_rationalesByPermission.TryGetValue(permission, out var rationale))
        {
            new AlertDialog.Builder(_activity)
                .SetTitle("Enable app permissions")!
                .SetMessage(rationale)!
                .SetNegativeButton("No thanks", (_, _) => callback(false))!
                .SetPositiveButton("Continue", (_, _) => LaunchPermissionRequestActivity(permission, callback))!
                .Show();
        }
        else
        {
            LaunchPermissionRequestActivity(permission, callback);
        }
    }

    private void LaunchPermissionRequestActivity(string permission, Action<bool> callback)
    {
        if (_pendingPermissionRequestCallback is not null)
        {
            throw new InvalidOperationException("Cannot perform multiple permission requests simultaneously.");
        }

        _pendingPermissionRequestCallback = callback;
        _requestPermissionLauncher.Launch(permission);
    }

    void IActivityResultCallback.OnActivityResult(Java.Lang.Object isGranted)
    {
        var callback = _pendingPermissionRequestCallback;
        _pendingPermissionRequestCallback = null;
        callback?.Invoke((bool)isGranted);
    }

    #region Unremarkable overrides
    // See: https://github.com/dotnet/maui/issues/6565
    public override JniPeerMembers JniPeerMembers => _blazorWebChromeClient.JniPeerMembers;
    public override Bitmap? DefaultVideoPoster => _blazorWebChromeClient.DefaultVideoPoster;
    public override Android.Views.View? VideoLoadingProgressView => _blazorWebChromeClient.VideoLoadingProgressView;
    public override void GetVisitedHistory(IValueCallback? callback)
        => _blazorWebChromeClient.GetVisitedHistory(callback);
    public override bool OnConsoleMessage(ConsoleMessage? consoleMessage)
        => _blazorWebChromeClient.OnConsoleMessage(consoleMessage);
    public override bool OnCreateWindow(WebView? view, bool isDialog, bool isUserGesture, Message? resultMsg)
        => _blazorWebChromeClient.OnCreateWindow(view, isDialog, isUserGesture, resultMsg);
    public override void OnGeolocationPermissionsHidePrompt()
        => _blazorWebChromeClient.OnGeolocationPermissionsHidePrompt();
    public override void OnHideCustomView()
        => _blazorWebChromeClient.OnHideCustomView();
    public override bool OnJsAlert(WebView? view, string? url, string? message, JsResult? result)
        => _blazorWebChromeClient.OnJsAlert(view, url, message, result);
    public override bool OnJsBeforeUnload(WebView? view, string? url, string? message, JsResult? result)
        => _blazorWebChromeClient.OnJsBeforeUnload(view, url, message, result);
    public override bool OnJsConfirm(WebView? view, string? url, string? message, JsResult? result)
        => _blazorWebChromeClient.OnJsConfirm(view, url, message, result);
    public override bool OnJsPrompt(WebView? view, string? url, string? message, string? defaultValue, JsPromptResult? result)
        => _blazorWebChromeClient.OnJsPrompt(view, url, message, defaultValue, result);
    public override void OnPermissionRequestCanceled(PermissionRequest? request)
        => _blazorWebChromeClient.OnPermissionRequestCanceled(request);
    public override void OnProgressChanged(WebView? view, int newProgress)
        => _blazorWebChromeClient.OnProgressChanged(view, newProgress);
    public override void OnReceivedIcon(WebView? view, Bitmap? icon)
        => _blazorWebChromeClient.OnReceivedIcon(view, icon);
    public override void OnReceivedTitle(WebView? view, string? title)
        => _blazorWebChromeClient.OnReceivedTitle(view, title);
    public override void OnReceivedTouchIconUrl(WebView? view, string? url, bool precomposed)
        => _blazorWebChromeClient.OnReceivedTouchIconUrl(view, url, precomposed);
    public override void OnRequestFocus(WebView? view)
        => _blazorWebChromeClient.OnRequestFocus(view);
    public override void OnShowCustomView(View? view, ICustomViewCallback? callback)
        => _blazorWebChromeClient.OnShowCustomView(view, callback);
    public override bool OnShowFileChooser(WebView? webView, IValueCallback? filePathCallback, FileChooserParams? fileChooserParams)
        => _blazorWebChromeClient.OnShowFileChooser(webView, filePathCallback, fileChooserParams);
    #endregion
}

3. 檔案 MainPage.xaml

新增 x:Name="_blazorWebView"

    <BlazorWebView x:Name="_blazorWebView" HostPage="wwwroot/index.html">
        <BlazorWebView.RootComponents>
            <RootComponent Selector="#app" ComponentType="{x:Type shared:App}" />
        </BlazorWebView.RootComponents>
    </BlazorWebView> 

4. 檔案 MainPage.xaml.cs

新增
_blazorWebView.BlazorWebViewInitialized += BlazorWebViewInitialized; _blazorWebView.BlazorWebViewInitializing += BlazorWebViewInitializing;

完整程式碼:

using LibraryShared;
using Microsoft.AspNetCore.Components.WebView;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Platform;
using System;
using static Microsoft.Maui.ApplicationModel.Permissions;
#if ANDROID
using AndroidX.Activity;
#endif

namespace BlazorMaui
{
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();
             
            _blazorWebView.BlazorWebViewInitialized += BlazorWebViewInitialized;
            _blazorWebView.BlazorWebViewInitializing += BlazorWebViewInitializing;
        }

        private void BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e)
        {
#if ANDROID
            if (e.WebView.Context?.GetActivity() is not ComponentActivity activity)
            {
                throw new InvalidOperationException($"The permission-managing WebChromeClient requires that the current activity be a '{nameof(ComponentActivity)}'.");
            }

            e.WebView.Settings.JavaScriptEnabled = true;
            e.WebView.Settings.AllowFileAccess = true;
            e.WebView.Settings.MediaPlaybackRequiresUserGesture = false;
            e.WebView.Settings.SetGeolocationEnabled(true);
            e.WebView.Settings.SetGeolocationDatabasePath(e.WebView.Context?.FilesDir?.Path);
            e.WebView.SetWebChromeClient(new PermissionManagingBlazorWebChromeClient(e.WebView.WebChromeClient!, activity));
#endif
        }

        private void BlazorWebViewInitializing(object? sender, BlazorWebViewInitializingEventArgs e)
        {
#if IOS || MACCATALYST                   
            e.Configuration.AllowsInlineMediaPlayback = true;
            e.Configuration.MediaTypesRequiringUserActionForPlayback = WebKit.WKAudiovisualMediaTypes.None;
#endif
        }
    }
}

4. 其他更改

由於工程是一個共用庫給多端用,先定義了一個介面用於注入服務到頁面呼叫演示功能

    public interface ITools
    {
        Task<string> CheckPermissionsCamera();
        Task<string> TakePhoto();

        Task<string> CheckPermissionsLocation();
        Task<string> GetCachedLocation();

        Task<string> GetCurrentLocation();

        Task<string> CheckMock();

        double DistanceBetweenTwoLocations();

        void ShowSettingsUI();
        string GetAppInfo();
    }

呼叫MAUI的API功能 BlazorMaui/Services/TestService.cs

#if WINDOWS
using Windows.Storage;
#endif
#if ANDROID
using Android.Webkit;
#endif
using BlazorShared.Services;
using System.Security.Permissions;

namespace LibraryShared
{
    public class TestService : ITools
    {
        public string GetAppInfo() {
            //讀取應用資訊
            string name = AppInfo.Current.Name;
            string package = AppInfo.Current.PackageName;
            string version = AppInfo.Current.VersionString;
            string build = AppInfo.Current.BuildString;
            return $"{name},{version}.{build}";
        }

        public void ShowSettingsUI()
        {
            //顯示應用設定
            AppInfo.Current.ShowSettingsUI();
        }

        public async Task<string> CheckPermissionsCamera()
        {
            //檢查許可權的當前狀態
            PermissionStatus status = await Permissions.CheckStatusAsync<Permissions.Camera>();

            //請求許可權
            if (status != PermissionStatus.Granted)
            {
                status = await Permissions.RequestAsync<Permissions.Camera>();
            }

            return status.ToString();
        }
        public async Task<string> CheckPermissionsLocation()
        {
            //檢查許可權的當前狀態
            PermissionStatus status = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();

            //請求許可權
            if (status != PermissionStatus.Granted)
            {
                status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
            }

            return status.ToString();
        }
        /// <summary>
        /// 拍照
        /// CapturePhotoAsync呼叫該方法以開啟相機,讓使用者拍照。 如果使用者拍照,該方法的返回值將是非 null 值。
        /// 以下程式碼範例使用媒體選取器拍攝照片並將其儲存到快取目錄:
        /// </summary>
        public async Task<string> TakePhoto()
        {
            await CheckPermissionsCamera();

            if (MediaPicker.Default.IsCaptureSupported)
            {
                FileResult photo = await MediaPicker.Default.CapturePhotoAsync();

                if (photo != null)
                {
                    // save the file into local storage
                    string localFilePath = Path.Combine(FileSystem.CacheDirectory, photo.FileName);

                    using Stream sourceStream = await photo.OpenReadAsync();
                    using FileStream localFileStream = File.OpenWrite(localFilePath);

                    await sourceStream.CopyToAsync(localFileStream);
                    return localFilePath;
                }
                return "photo null";

            }

            return null;
        }

        /// <summary>
        /// 獲取最後一個已知位置, 裝置可能已快取裝置的最新位置。
        /// 使用此方法 GetLastKnownLocationAsync 存取快取的位置(如果可用)。
        /// 這通常比執行完整位置查詢更快,但可能不太準確。
        /// 如果不存在快取位置,此方法將 null返回 。
        /// </summary>
        /// <returns></returns>
        public async Task<string> GetCachedLocation()
        {
            await CheckPermissionsLocation();
            string result = null;
            try
            {
                Location location = await Geolocation.Default.GetLastKnownLocationAsync();

                if (location != null)
                {
                    result = $"Latitude: {location.Latitude}, Longitude: {location.Longitude}, Altitude: {location.Altitude}";
                    Console.WriteLine(result);
                    return result;
                }
            }
            catch (FeatureNotSupportedException fnsEx)
            {
                // Handle not supported on device exception
                result = $"not supported on device, {fnsEx.Message}";
            }
            catch (FeatureNotEnabledException fneEx)
            {
                // Handle not enabled on device exception
                result = $"not enabled on device, {fneEx.Message}";
            }
            catch (PermissionException pEx)
            {
                // Handle permission exception
                result = $"permission, {pEx.Message}";
            }
            catch (Exception ex)
            {
                // Unable to get location
                result = $"Unable to get location, {ex.Message}";
            }

            return result ?? "None";
        }

        private CancellationTokenSource _cancelTokenSource;
        private bool _isCheckingLocation;


        /// <summary>
        /// 獲取當前位置
        /// 雖然檢查裝置 的最後已知位置 可能更快,但它可能不準確。
        /// 使用該方法 GetLocationAsync 查詢裝置的當前位置。
        /// 可以設定查詢的準確性和超時。
        /// 最好是使用 GeolocationRequest 和 CancellationToken 引數的方法過載,
        /// 因為可能需要一些時間才能獲取裝置的位置。
        /// </summary>
        /// <returns></returns>
        public async Task<string> GetCurrentLocation()
        {
            await CheckPermissionsLocation();
            string result = null;
            try
            {
                _isCheckingLocation = true;

                GeolocationRequest request = new GeolocationRequest(GeolocationAccuracy.Medium, TimeSpan.FromSeconds(10));

                _cancelTokenSource = new CancellationTokenSource();

#if IOS
                //從 iOS 14 開始,使用者可能會限制應用檢測完全準確的位置。
                //該 Location.ReducedAccuracy 屬性指示位置是否使用降低的準確性。
                //若要請求完全準確性,請將 GeolocationRequest.RequestFullAccuracy 屬性設定為 true
                request.RequestFullAccuracy = true;
#endif

                Location location = await Geolocation.Default.GetLocationAsync(request, _cancelTokenSource.Token);

                if (location != null)
                {
                    result = $"Latitude: {location.Latitude}, Longitude: {location.Longitude}, Altitude: {location.Altitude}";
                    Console.WriteLine(result);
                    return result;
                }
            }
            catch (FeatureNotSupportedException fnsEx)
            {
                // Handle not supported on device exception
                result = $"not supported on device, {fnsEx.Message}";
            }
            catch (FeatureNotEnabledException fneEx)
            {
                // Handle not enabled on device exception
                result = $"not enabled on device, {fneEx.Message}";
            }
            catch (PermissionException pEx)
            {
                // Handle permission exception
                result = $"permission, {pEx.Message}";
            }
            catch (Exception ex)
            {
                // Unable to get location
                result = $"Unable to get location, {ex.Message}";
            }
            finally
            {
                _isCheckingLocation = false;
            }
            return result ?? "None";
        }
    }
}

MauiProgram.cs檔案注入

builder.Services.AddSingleton<ITools, TestService>();

razor

        <Button Text="定位許可權" OnClick="檢查定位許可權" />
        <span>@定位許可權</span><br/><br/>
        <Button Text="攝像機許可權" OnClick="檢查攝像機許可權" />
        <span>@攝像機許可權</span><br /><br />
        <Button Text="定位" OnClick="獲取定位" />
        <span>@Locations</span><br /><br />
        <Button Text="TakePhoto" OnClick="TakePhoto" />
        <span>@PhotoFilename</span><br /><br />
        <Button Text="ShowSettings" OnClick="ShowSettingsUI" />
        <span>@version</span><br /><br />

@code{
        [Inject] protected ITools Tools { get; set; }

        private string Locations;
        private string PhotoFilename;
        private string version;
        private string 定位許可權;
        private string 攝像機許可權;

        async Task 獲取定位() => Locations = await Tools.GetCurrentLocation();
        async Task TakePhoto() => PhotoFilename = await Tools.TakePhoto();
        async Task 檢查定位許可權() => 定位許可權 = await Tools.CheckPermissionsLocation();
        async Task 檢查攝像機許可權() => 攝像機許可權 = await Tools.CheckPermissionsCamera();
        void ShowSettingsUI() =>   Tools.ShowSettingsUI();
}

最終效果

專案地址

https://github.com/densen2014/BlazorMaui

https://gitee.com/densen2014/BlazorMaui

參考資料

Permissions
https://docs.microsoft.com/en-us/dotnet/maui/platform-integration/appmodel/permissions?tabs=android

Geolocation
https://docs.microsoft.com/en-us/dotnet/maui/platform-integration/device/geolocation?tabs=windows

MauiBlazorPermissionsExample
https://github.com/MackinnonBuck/MauiBlazorPermissionsExample

關聯專案

FreeSql QQ群:4336577、8578575、52508226

BA & Blazor QQ群:795206915、675147445

知識共用許可協定

本作品採用 知識共用署名-非商業性使用-相同方式共用 4.0 國際許可協定 進行許可。歡迎轉載、使用、重新發布,但務必保留文章署名AlexChow(包含連結: https://github.com/densen2014 ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。如有任何疑問,請與我聯絡

AlexChow

今日頭條 | 部落格園 | 知乎 | Gitee | GitHub