Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: Fully Support AoT Compilation & CIL Stripping for Android #44855

Closed
Tracked by #80938
jxbrenner opened this issue Nov 18, 2020 · 27 comments
Closed
Tracked by #80938
Assignees
Milestone

Comments

@jxbrenner
Copy link

jxbrenner commented Nov 18, 2020

Provide AoT compilation and CIL stripping when targeting Android, and fully support in Visual Studio starting with .NET 6. At a minimum, this request is for static compilation to armeabi-v7a and arm64-v8a architectures and CIL stripping to reduce assemblies to nothing but metadata.

The primary motivation for the request is that APKs containing IL are trivial to unpack and decompile (bundled or not), presenting unnecessary risks to security and IP. Secondary motivation is performance related.

Alternative mobile app development frameworks such as Kotlin and Flutter provide static compilation. This request is an opportunity for Microsoft to keep up with the status-quo and appeal to businesses that wish to use MAUI for risk-conscious, closed-source mobile app development efforts.

The current state of affairs with Xamarin and CIL stripping can be found in dotnet/android#1218. In summary, CIL stripping for armeabi-v7a once worked but has since been broken in regressions, and currently only works for arm64-v8a targets. The feature is undocumented and has only experimental support. As it stands, Xamarin Android forces us to choose between the armeabi-v7a user base and packages from which our original source code cannot be obtained.

Tagging @akoeplinger as per the docs.

@Dotnet-GitSync-Bot
Copy link
Collaborator

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

@Dotnet-GitSync-Bot Dotnet-GitSync-Bot added the untriaged New issue has not been triaged by the area owner label Nov 18, 2020
@ghost
Copy link

ghost commented Nov 18, 2020

Tagging subscribers to this area: @CoffeeFlux
See info in area-owners.md if you want to be subscribed.

Issue Details

Provide AoT compilation and CIL stripping for Android builds, both armeabi-v7a and arm64-v8a targets, and fully support in Visual Studio starting with .NET 6. APKs containing IL are trivial to unpack and decompile (bundled or not), presenting major risks to security and IP.

Considering armeabi-v7a market share, the experimental support that only works for arm64-v8a targets in Xamarin.Android is a major deterrent to any serious development with Microsoft's cross-platform app building solution. The current state of affairs for .NET forces us to choose between the armeabi-v7a user base and secure source code suitable for distribution on app stores, which is a no-go for any risk conscious closed-source development effort.

See dotnet/android#1218 for some history and additional user feedback.

Tagging @akoeplinger as per the docs.

Author: jbrenner15
Assignees: -
Labels:

area-VM-meta-mono, untriaged

Milestone: -

@CoffeeFlux CoffeeFlux added area-Codegen-meta-mono and removed area-VM-meta-mono untriaged New issue has not been triaged by the area owner labels Nov 19, 2020
@ghost
Copy link

ghost commented Nov 19, 2020

Tagging subscribers to this area:
See info in area-owners.md if you want to be subscribed.

Issue Details

Provide AoT compilation and CIL stripping for Android builds, both armeabi-v7a and arm64-v8a targets, and fully support in Visual Studio starting with .NET 6. APKs containing IL are trivial to unpack and decompile (bundled or not), presenting major risks to security and IP.

Considering armeabi-v7a market share, the experimental support that only works for arm64-v8a targets in Xamarin.Android is a major deterrent to any serious development with Microsoft's cross-platform app building solution. The current state of affairs for .NET forces us to choose between the armeabi-v7a user base and secure source code suitable for distribution on app stores, which is a no-go for any risk conscious closed-source development effort.

See dotnet/android#1218 for some history and additional user feedback.

Tagging @akoeplinger as per the docs.

Author: jbrenner15
Assignees: -
Labels:

area-Codegen-meta-mono

Milestone: -

@jxbrenner
Copy link
Author

I've made some clarifications in the feature request. @SamMonoRT, @lambdageek, @CoffeeFlux, what is the likelihood of getting a user story added to the 6.0.0 milestone?

As MAUI developer targeting Android, I want to be able to strip the CIL from our shared and head projects when building on armeabi-v7a and arm64-v8a architectures, reducing the resulting assemblies to nothing but metadata, so that my app packages can't be decompiled back to their original source code.

Of course machine code can be disassembled and then decompiled, but the loss of information that happens when assembling the IL code is very far removed from the original, maintainable C# source that you get back when decompiling from CIL (regardless of obfuscation).

@marek-safar
Copy link
Contributor

We would like to support AOT mode for Android RIDs but we will probably run out of time to finish the work in .NET6 time frame.

@jxbrenner
Copy link
Author

jxbrenner commented Feb 21, 2021

Thanks for your reply @marek-safar. Could you clarify whether the AOT compilation you're working towards for Android is a profiled AOT, or a full AOT where the JIT engine could be dropped from the package? The most dialog I could find on the subject was in /issues/34693, is there a more relevant issue to track?

@SamMonoRT SamMonoRT added this to the 7.0.0 milestone Jul 12, 2021
@jxbrenner jxbrenner changed the title Feature Request: Fully Support Hybrid AoT Compilation & CIL Stripping for Android Feature Request: Fully Support AoT Compilation & CIL Stripping for Android Jun 11, 2022
@MaximMikhisor
Copy link

Please implement this feature.

@SamMonoRT
Copy link
Member

cc @fanyang-mono @jonathanpeppers

Moving this to next milestone, but something we will want to consider in next few months.

@SamMonoRT SamMonoRT modified the milestones: 7.0.0, 8.0.0 Aug 10, 2022
@jonathanpeppers
Copy link
Member

I think the two parts here are:

  • Get Hybrid AOT working in .NET 6+ Android projects -- unknown if it currently works
  • cil-strip .NET assemblies -- the iOS workload is using this, so it may be straightforward to do it in Android projects

We have not really focused on Hybrid AOT due to its experimental status and lack of demand so far. If we had some app size or performance benefits, that might be a good reason to implement this.

@MaximMikhisor
Copy link

"lack of demand so far"
Our project has demand in Hybrid AOT. Particulary interesting in removing NET assemblies from Android package to avoid deobfuscation possibility.

@nexxuno
Copy link

nexxuno commented Aug 10, 2022

I think the lack of demand comes from missing features and not the other way around in this case.

@jxbrenner
Copy link
Author

jxbrenner commented Aug 11, 2022

I think the two parts here are:

* Get Hybrid AOT working in .NET 6+ Android projects -- unknown if it currently works

* `cil-strip` .NET assemblies -- the iOS workload is using this, so it may be straightforward to do it in Android projects

We have not really focused on Hybrid AOT due to its experimental status and lack of demand so far. If we had some app size or performance benefits, that might be a good reason to implement this.

@jonathanpeppers, @SamMonoRT

Isn't the intent of setting the AndroidAotMode build property to Hybrid to tell MSBuild to IL strip the assemblies? So aren't the two parts really just one, create a build task that executes the Mono CIL Stripper against statically compiled assemblies?

I've always been confused about the "hybrid" nomenclature and referring to it as this separate kind of AoT compilation. I get that in MonoAndroid it drops Mono.Android as a target for cil-strip (I imagine there was some issue with stripping the Android bindings), so it's technically not "full" AoT - perhaps this is why its called as such?

There already seems to be a commitment here, ex. it's been assigned to a milestone? Are mitigating unnecessary risks to intellectual property and security not good reasons to implement and fully support?

I think a good two-part approach here could be:

  1. Create and fully support a build task that executes Mono CIL Stripper against the shared and head project assemblies when statically compiling. Preferably supporting all ABIs, but even armeabi-v7a and arm64-v8 would be a good start. Perhaps you want to call this something other than hybrid since it's undocumented anyways and the naming is a bit confusing. Perhaps a boolean CilStrip property?
  2. Improve the aforementioned build task to the point where it strips all managed assemblies for all ABIs. Personally I only care about the "for all ABIs" part, but I imagine stripping all managed assemblies would decrease app size.

Item 1 completely mitigates the security and IP risks and I assume is easier to accomplish. This is probably naive but perhaps it can be accomplished by fixing whatever broke IL stripping in Xamarin.Android 12.3 (see dotnet/android#7088), applying the fix to .NET SDK, and just moving the CilStrip build task back to Xamarin.Android.Common.targets from Xamarin.Android.Legacy.targets since legacy doesn't apply when you use AndroidNETSdk?

@jonathanpeppers
Copy link
Member

I don't think it is quite as simple as you describe here. If we called the <CilStrip/> task in .NET 6, there is no cil-strip executable to run. We need a pack to consume & redistribute it somehow. We could look into how the ios workload is doing this, if we can share the same binary.

There is also a "hybrid" AOT mode value set at runtime:

https://github.com/xamarin/xamarin-android/blob/becca174b071736a294899fdadfd9ae82b51ce26/src/monodroid/jni/monodroid-glue.cc#L2336

Since, literally no one has ever tried it (I haven't), I would expect apps would crash (maybe I'm just pessimistic?) -- and we'd need to fix something.

Can you give it a try, just using our .NET 6 stable packages? Assuming apps build, if you enable full logging, it should show what happens with AOT mode at runtime:

adb shell setprop debug.mono.log default,mono_log_level=debug,mono_log_mask=aot

Then look at the log, such as adb logcat -d > log.txt. You should see a message like:

08-09 09:52:29.364 10088 10088 I monodroid: Mono AOT mode: normal

Except it would say hybrid? Then hopefully apps don't crash?

@jxbrenner
Copy link
Author

I have no doubt my understanding of the problem is superficial and incomplete. Thanks for taking the time to help me understand better. So hybrid isn't just some arbitrary value name for the AndroidAotMode build property, it's the setting for mono_jit_set_aot_mode that tells the runtime to still include the JIT so it can execute IL stripped assemblies with dynamic features?

So is the path forward here:

  1. Statically compile everything like iOS so IL stripped code works with full AoT, or
  2. Fix whatever JIT issues come up with IL stripped code and fully support hybrid AoT?

Additionally, is the goal to solve the issue for all Android ABIs or just a subset?

Whatever it is, I'm just very hopeful this issue is resolved before Google Play requires apps published with an SDK greater than whatever is supported by Xamarin.Android 12.2. That's where I'm stuck until either the regression that breaks IL stripping in 12.3 is fixed or I have a path forward to a unified .NET that at least supports IL stripping of my shared and head project assemblies. Preferably a fair bit sooner then that so I have time to rewrite in native or move to a different cross-platform framework instead of angry clients and no revenue.

I believe that the cil-strip binary is the same for Android and iOS.

I'll try to run a .NET 6 app with hybrid AoT and post the logs. Should I try it with and without IL stripping? I imagine I need to strip the IL from an assembly with some dynamic code to fully test, but I appreciate that just knowing the app doesn't explode with just setting Mono AOT mode to hybrid is helpful as well.

@jonathanpeppers
Copy link
Member

Should I try it with and without IL stripping?

I don't think you'll actually be able to do the stripping part easily -- so we're just checking if things at runtime work at all.

@jxbrenner
Copy link
Author

No trouble with the Android Application template in Visual Studio 17.3.0.

Here's the project file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0-android</TargetFramework>
    <SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
    <OutputType>Exe</OutputType>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <ApplicationId>com.companyname.AndroidApp2</ApplicationId>
    <ApplicationVersion>1</ApplicationVersion>
    <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
    <PlatformTarget>ARM64</PlatformTarget>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
	<RunAOTCompilation>False</RunAOTCompilation>
	<AndroidDexTool>d8</AndroidDexTool>
	<AndroidEnableMultiDex>False</AndroidEnableMultiDex>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
	<DebugSymbols>False</DebugSymbols>
	<DebugType>Portable</DebugType>
    <RunAOTCompilation>True</RunAOTCompilation>
	<AndroidAotMode>Hybrid</AndroidAotMode>
	<AndroidAotAdditionalArguments>no-write-symbols,nodebug</AndroidAotAdditionalArguments>
	<AndroidEnableProfiledAot>False</AndroidEnableProfiledAot>
	<EnableLLVM>True</EnableLLVM>
	<AndroidDexTool>d8</AndroidDexTool>
	<AndroidEnableMultiDex>False</AndroidEnableMultiDex>
	<AndroidLinkTool>r8</AndroidLinkTool>
	<AndroidLinkMode>SdkOnly</AndroidLinkMode>
	<AndroidCreatePackagePerAbi>True</AndroidCreatePackagePerAbi>
	<RuntimeIdentifiers>android-arm64</RuntimeIdentifiers>
  </PropertyGroup>
</Project>

I'm targeting SDK 31 and running it on an ARM 64 device. Did not crash.

Here's the runtime saying it's configured for hybrid AOT:

08-11 15:21:23.102 17981 17981 I monodroid: Generated hash 0xfed35cb3 for package name com.companyname.AndroidApp2
08-11 15:21:23.102 17981 17981 I monodroid: Mono AOT mode: hybrid
08-11 15:21:23.102 17981 17981 I monodroid: Creating XDG directory: /data/user/0/com.companyname.AndroidApp2/files/.local/share
08-11 15:21:23.102 17981 17981 I monodroid: Creating XDG directory: /data/user/0/com.companyname.AndroidApp2/files/.config
08-11 15:21:23.104 17981 17981 I monodroid: Setting up for DSO lookup directly in the APK
08-11 15:21:23.104 17981 17981 W monodroid: Creating public update directory: `/data/user/0/com.companyname.AndroidApp2/files/.__override__`
08-11 15:21:23.104 17981 17981 I monodroid: Using runtime path: /data/app/com.companyname.AndroidApp2-hxAJXB2eGe89qRcYhrNXJw==/lib/arm64
08-11 15:21:23.104 17981 17981 I monodroid: Probing for Mono AOT mode
08-11 15:21:23.104 17981 17981 I monodroid: Enabling AOT mode in Mono
08-11 15:21:23.104 17981 17981 I monodroid: Probing if we should use LLVM
08-11 15:21:23.104 17981 17981 I monodroid: Enabling LLVM mode in Mono

@gabriel-kozma
Copy link

Yeah, my project also uses hybrid AOT and I'm stuck at Xamarin.Android 12.2, whatever broke in 12.3 would be just fine, but not even that

I was looking forward for this feature to start migrating in to MAUI

@jxbrenner
Copy link
Author

@gabriel-kozma
Yes, we'd like to migrate to MAUI as well but this issue blocks us.

@jonathanpeppers
Does the information I provided help? What else can I do to help progress the issue? I might be able to manage manually building an IL stripped package, should I do this and share results? I'm quite curious if it will be successful, fail the same as Xamarin.Android 12.3, or fail differently. Or is this something you are already looking into?

@marek-safar @SamMonoRT
We would really appreciate some insight as to whether this feature request will come by way of entirely static compilation so there's no need for the JIT when IL stripping, or by getting hybrid AoT working to support dynamic compilation. A ballpark estimate of when it will be implemented will helps us assess risk as well.

@jxbrenner
Copy link
Author

@marek-safar @SamMonoRT @fanyang-mono, just checking in to understand the likelihood of resolving the regressions to AoT and CIL stripping in .NET 8.

With Android 13 and iOS 16 being the final platform versions supported by Xamarin and support for Xamarin ending on 2024-05-01, .NET 8 is the last oppotunity for MAUI to live up to its promise of allowing an upgrade of our Xamarin apps to MAUI without a rewrite. Without closing the gap created by .NET Android's lack of AoT complitlation and CIL stripping in .NET 8, we'll see our Xamarin apps dropped by the app stores before .NET 9. Migrating to MAUI without these features is also a non-starter because our apps will no longer meet our customer's security requirements.

@SamMonoRT
Copy link
Member

Yes, we are on track to deliver this in next couple months, unless we hit any unforeseen issues.

@fanyang-mono
Copy link
Member

The overall progress of this has been tracked by #80953

@fanyang-mono
Copy link
Member

The change for enabling this feature for Android apps is dotnet/android#8172

jonpryor pushed a commit to dotnet/android that referenced this issue Aug 22, 2023
Context: xamarin/monodroid@388bf4b
Context: 59ec488
Context: c929289
Context: 88215f9
Context: dotnet/runtime#86722
Context: dotnet/runtime#44855

Once Upon A Time™ we had a brilliant thought: if AOT pre-compiles C#
methods, do we need the managed method anymore?  Removing the C#
method body would allow assemblies to be smaller.  ("Even better",
iOS does this too!  Why Can't Android™?!)

While the idea is straightforward, implementation was not: iOS uses 
["Full" AOT][0], which AOT's *all* methods into a form that doesn't
require a runtime JIT.  This allowed iOS to run [`cil-strip`][1],
removing all method bodies from all managed types.

At the time, Xamarin.Android only supported "normal" AOT, and normal
AOT requires a JIT for certain constructs such as generic types and
generic methods.  This meant that attempting to run `cil-strip`
would result in runtime errors if a method body was removed that was
actually required at runtime.  (This was particularly bad because
`cil-strip` could only remove *all* method bodies, not some!)

This limitation was relaxed with the introduction of "Hybrid" AOT,
which is "Full AOT while supporting a JIT".  This meant that *all*
methods could be AOT'd without requiring a JIT, which allowed method
bodies to be removed; see xamarin/monodroid@388bf4b3.

Unfortunately, this wasn't a great long-term solution:

 1. Hybrid AOT was restricted to Visual Studio Enterprise customers.
 2. Enabling Hybrid AOT would slow down Release configuration builds.
 3. Hybrid AOT would result in larger apps.
 4. As a consequence of (1), it didn't get as much testing
 5. `cil-strip` usage was dropped as part of the .NET 5+ migration
    (c929289)

Re-intoduce IL stripping for .NET 8.

Add a new `$(AndroidStripILAfterAOT)` MSBuild property.  When true,
the `<MonoAOTCompiler/>` task will track which method bodies
were actually AOT'd, storing this information into
`%(_MonoAOTCompiledAssemblies.MethodTokenFile)`, and the new
`<ILStrip/>` task will update the input assemblies, removing all
method bodies that can be removed.

By default setting `$(AndroidStripILAfterAOT)`=true will *override*
the default `$(AndroidEnableProfiledAot)` setting, allowing all
trimmable AOT'd methods to be removed.  Profiled AOT and IL stripping
can be used together by explicitly setting both within the `.csproj`:

	<PropertyGroup>
	  <AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
	  <AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
	</PropertyGroup>

`.apk` size results for a `dotnet new android` app:

| `$(AndroidStripILAfterAOT)` | `$(AndroidEnableProfiledAot)` | `.apk` size   |
| --------------------------- | ----------------------------- | ------------- |
| true                        | true                          | 7.7MB         |
| true                        | false                         | 8.1MB         |
| false                       | true                          | 7.7MB         |
| false                       | false                         | 8.4MB         |

Note that `$(AndroidStripILAfterAOT)`=false and
`$(AndroidEnableProfiledAot)`=true is the *default* Release
configuration environment, for 7.7MB.

A project that *only* sets `$(AndroidStripILAfterAOT)`=true implicitly
sets `$(AndroidEnableProfiledAot)`=false, resulting in an 8.1MB app.

Co-authored-by: Fan Yang <yangfan@microsoft.com>

[0]: https://www.mono-project.com/docs/advanced/aot/#full-aot
[1]: https://github.com/mono/mono/tree/2020-02/mcs/tools/cil-strip
@SamMonoRT
Copy link
Member

PR completing this work was merged - dotnet/android#8172

/cc @fanyang-mono - please add details when that will be available for public release, and close this issue.

jonathanpeppers added a commit to dotnet/android that referenced this issue Aug 22, 2023
Context: xamarin/monodroid@388bf4b
Context: 59ec488
Context: c929289
Context: 88215f9
Context: dotnet/runtime#86722
Context: dotnet/runtime#44855

Once Upon A Time™ we had a brilliant thought: if AOT pre-compiles C#
methods, do we need the managed method anymore?  Removing the C#
method body would allow assemblies to be smaller.  ("Even better",
iOS does this too!  Why Can't Android™?!)

While the idea is straightforward, implementation was not: iOS uses 
["Full" AOT][0], which AOT's *all* methods into a form that doesn't
require a runtime JIT.  This allowed iOS to run [`cil-strip`][1],
removing all method bodies from all managed types.

At the time, Xamarin.Android only supported "normal" AOT, and normal
AOT requires a JIT for certain constructs such as generic types and
generic methods.  This meant that attempting to run `cil-strip`
would result in runtime errors if a method body was removed that was
actually required at runtime.  (This was particularly bad because
`cil-strip` could only remove *all* method bodies, not some!)

This limitation was relaxed with the introduction of "Hybrid" AOT,
which is "Full AOT while supporting a JIT".  This meant that *all*
methods could be AOT'd without requiring a JIT, which allowed method
bodies to be removed; see xamarin/monodroid@388bf4b3.

Unfortunately, this wasn't a great long-term solution:

 1. Hybrid AOT was restricted to Visual Studio Enterprise customers.
 2. Enabling Hybrid AOT would slow down Release configuration builds.
 3. Hybrid AOT would result in larger apps.
 4. As a consequence of (1), it didn't get as much testing
 5. `cil-strip` usage was dropped as part of the .NET 5+ migration
    (c929289)

Re-intoduce IL stripping for .NET 8.

Add a new `$(AndroidStripILAfterAOT)` MSBuild property.  When true,
the `<MonoAOTCompiler/>` task will track which method bodies
were actually AOT'd, storing this information into
`%(_MonoAOTCompiledAssemblies.MethodTokenFile)`, and the new
`<ILStrip/>` task will update the input assemblies, removing all
method bodies that can be removed.

By default setting `$(AndroidStripILAfterAOT)`=true will *override*
the default `$(AndroidEnableProfiledAot)` setting, allowing all
trimmable AOT'd methods to be removed.  Profiled AOT and IL stripping
can be used together by explicitly setting both within the `.csproj`:

	<PropertyGroup>
	  <AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
	  <AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
	</PropertyGroup>

`.apk` size results for a `dotnet new android` app:

| `$(AndroidStripILAfterAOT)` | `$(AndroidEnableProfiledAot)` | `.apk` size   |
| --------------------------- | ----------------------------- | ------------- |
| true                        | true                          | 7.7MB         |
| true                        | false                         | 8.1MB         |
| false                       | true                          | 7.7MB         |
| false                       | false                         | 8.4MB         |

Note that `$(AndroidStripILAfterAOT)`=false and
`$(AndroidEnableProfiledAot)`=true is the *default* Release
configuration environment, for 7.7MB.

A project that *only* sets `$(AndroidStripILAfterAOT)`=true implicitly
sets `$(AndroidEnableProfiledAot)`=false, resulting in an 8.1MB app.

Co-authored-by: Fan Yang <yangfan@microsoft.com>

[0]: https://www.mono-project.com/docs/advanced/aot/#full-aot
[1]: https://github.com/mono/mono/tree/2020-02/mcs/tools/cil-strip
@fanyang-mono
Copy link
Member

The new feature should be available in .NET8 RC1, which should be available around mid-September.

@jxbrenner Will you be able to try it out and let us know if it meets your requirements?

@jxbrenner
Copy link
Author

@fanyang-mono Absolutely, I'll test and report back as soon as RC1 is released.

@SamMonoRT
Copy link
Member

@fanyang-mono @jonathanpeppers - do we need to update any documentation with this ?

also @jxbrenner - I'll mark this as complete and close the issue. If you encounter issues with RC1, please re-open and/or create a new issue with the specific failure and we'll try to backport a fix for 8.0 release.

@jonathanpeppers
Copy link
Member

We usually start with this doc of our public MSBuild properties that is synced to MS docs:

https://github.com/xamarin/xamarin-android/blob/main/Documentation/guides/building-apps/build-properties.md#androidstripilafteraot

It does not go into details on how it works, though.

@ghost ghost locked as resolved and limited conversation to collaborators Sep 24, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests