Skip to content

Commit af12874

Browse files
[TeamsDevices] [nGMS] Add DeviceCodeFlow (DCF) to Broker (#1752)
### **What** This PR includes adding DCF to Broker for Teams Devices' nGMS changes. Feature is controlled by `FlightNames.ENABLE_NGMS_FLOW` and is currently set to false. ### **Why** and **How** nGMS Teams Devices Design PR: https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview/pullrequest/6564?_a=files // TODO: 1) Add Telemetry once Msal to Broker telemetry is setup. Currently, I've added OTel spans for broker side 2) Add UTs ### **Testing and Validation:** Tested DCF` authResult ` flow and able to get the User code and other DCF info successfully ![MSAL_DCF_FETCH_AUTH_RESULT](https://user-images.githubusercontent.com/107152318/213335492-b6f5b856-f758-467a-b7a4-8eb19d318251.png) Tested DCF `acquireToken` by signing into `/devicelogin` and able to get token successfully ![MSAL_DCF_ACQUIRE_TOKEN](https://user-images.githubusercontent.com/107152318/213337703-6dd0fcf2-a748-44b6-9aac-95ad26b71a77.png) Validated that an exception shall be thrown if nGMS Feature flag (FlightNames.ENABLE_NGMS_FLOW) is disabled ![MSAL_DCF_NOT_SUPPORTED_BEFORE_NGMS](https://user-images.githubusercontent.com/107152318/213335855-6a58dea4-c370-44f8-9372-6ec586fc45fe.png) --------- Co-authored-by: REDMOND\amritsaini <[email protected]>
1 parent 68ccf78 commit af12874

11 files changed

+722
-9
lines changed

common

Submodule common updated 37 files
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// All rights reserved.
3+
//
4+
// This code is licensed under the MIT License.
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files(the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions :
12+
//
13+
// The above copyright notice and this permission notice shall be included in
14+
// all copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
// THE SOFTWARE.
23+
package com.microsoft.identity.client;
24+
25+
public class DeviceCodeFlowParameters extends TokenParameters {
26+
27+
public DeviceCodeFlowParameters(DeviceCodeFlowParameters.Builder builder) {
28+
super(builder);
29+
}
30+
31+
public static class Builder extends TokenParameters.Builder<DeviceCodeFlowParameters.Builder> {
32+
33+
@Override
34+
public DeviceCodeFlowParameters.Builder self() {
35+
return this;
36+
}
37+
38+
public DeviceCodeFlowParameters build() {
39+
return new DeviceCodeFlowParameters(this);
40+
}
41+
}
42+
43+
}

msal/src/main/java/com/microsoft/identity/client/IPublicClientApplication.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,16 @@
2525
import android.app.Activity;
2626

2727
import androidx.annotation.NonNull;
28+
import androidx.annotation.Nullable;
2829
import androidx.annotation.WorkerThread;
2930

31+
import com.microsoft.identity.client.claims.ClaimsRequest;
3032
import com.microsoft.identity.client.exception.MsalException;
3133
import com.microsoft.identity.common.java.util.TaskCompletedCallbackWithError;
3234

3335
import java.util.Date;
3436
import java.util.List;
37+
import java.util.UUID;
3538

3639
public interface IPublicClientApplication {
3740

@@ -89,6 +92,21 @@ void acquireToken(@NonNull final Activity activity,
8992
@WorkerThread
9093
IAuthenticationResult acquireTokenSilent(@NonNull final AcquireTokenSilentParameters acquireTokenSilentParameters) throws InterruptedException, MsalException;
9194

95+
// /**
96+
// * Perform the Device Code Flow (DCF) protocol to allow a device without input capability to authenticate and get a new access token.
97+
// * This flow is now supported in Broker as well. It also supports requesting Claims using the "claims" Request. Parameter.
98+
// *
99+
// * @param scopes the desired access scopes
100+
// * @param callback callback object used to communicate with the API throughout the protocol
101+
// * @param claims claims Authentication Request parameter requests that specific Claims be returned from the UserInfo Endpoint and/or in the ID Token.
102+
// * @param correlationId correlation id of this request
103+
// *
104+
// * Important: Use of this API requires setting the minimum_required_broker_protocol_version to
105+
// * "13.0" or higher.
106+
// * Note: This API is in testing phase and might return not supported error until fully supported.
107+
// */
108+
// void acquireTokenWithDeviceCode(@NonNull List<String> scopes, @NonNull final DeviceCodeFlowCallback callback, @Nullable final ClaimsRequest claims, @Nullable final UUID correlationId);
109+
92110
/**
93111
* Perform the Device Code Flow (DCF) protocol to allow a device without input capability to authenticate and get a new access token.
94112
* Currently, flow is only supported in local MSAL. No Broker support.

msal/src/main/java/com/microsoft/identity/client/PublicClientApplication.java

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,6 @@
8282
import com.microsoft.identity.common.crypto.AndroidAuthSdkStorageEncryptionManager;
8383
import com.microsoft.identity.common.internal.broker.BrokerValidator;
8484
import com.microsoft.identity.common.internal.cache.SharedPreferencesFileManager;
85-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommand;
86-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommandCallback;
8785
import com.microsoft.identity.common.internal.commands.GenerateShrCommand;
8886
import com.microsoft.identity.common.internal.commands.GetDeviceModeCommand;
8987
import com.microsoft.identity.common.internal.controllers.LocalMSALController;
@@ -100,6 +98,8 @@
10098
import com.microsoft.identity.common.java.cache.IShareSingleSignOnState;
10199
import com.microsoft.identity.common.java.cache.MsalOAuth2TokenCache;
102100
import com.microsoft.identity.common.java.commands.CommandCallback;
101+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommand;
102+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommandCallback;
103103
import com.microsoft.identity.common.java.commands.InteractiveTokenCommand;
104104
import com.microsoft.identity.common.java.commands.SilentTokenCommand;
105105
import com.microsoft.identity.common.java.commands.parameters.CommandParameters;
@@ -136,6 +136,7 @@
136136
import java.util.HashMap;
137137
import java.util.List;
138138
import java.util.Map;
139+
import java.util.UUID;
139140
import java.util.concurrent.ExecutionException;
140141
import java.util.concurrent.ExecutorService;
141142
import java.util.concurrent.Executors;
@@ -1838,6 +1839,49 @@ public void onError(MsalException exception) {
18381839
}
18391840
}
18401841

1842+
// public void acquireTokenWithDeviceCode(@NonNull List<String> scopes, @NonNull final DeviceCodeFlowCallback callback, @Nullable final ClaimsRequest claimsRequest, @Nullable final UUID correlationId) {
1843+
// DeviceCodeFlowParameters.Builder builder = new DeviceCodeFlowParameters.Builder();
1844+
//
1845+
// if (null != correlationId) {
1846+
// builder.withCorrelationId(correlationId);
1847+
// }
1848+
//
1849+
// DeviceCodeFlowParameters deviceCodeFlowParameters =
1850+
// builder.withScopes(scopes)
1851+
// .withClaims(claimsRequest)
1852+
// .build();
1853+
//
1854+
// final DeviceCodeFlowCommandParameters commandParameters = CommandParametersAdapter
1855+
// .createDeviceCodeFlowWithClaimsCommandParameters(
1856+
// mPublicClientConfiguration,
1857+
// mPublicClientConfiguration.getOAuth2TokenCache(),
1858+
// deviceCodeFlowParameters);
1859+
//
1860+
// final DeviceCodeFlowCommandCallback deviceCodeFlowCommandCallback = getDeviceCodeFlowCommandCallback(callback);
1861+
//
1862+
// try {
1863+
// final DeviceCodeFlowCommand deviceCodeFlowCommand = new DeviceCodeFlowCommand(
1864+
// commandParameters,
1865+
// MSALControllerFactory.getDefaultController(
1866+
// mPublicClientConfiguration.getAppContext(),
1867+
// commandParameters.getAuthority(),
1868+
// mPublicClientConfiguration
1869+
// ),
1870+
// deviceCodeFlowCommandCallback,
1871+
// PublicApiId.DEVICE_CODE_FLOW_WITH_CLAIMS_AND_CALLBACK
1872+
// );
1873+
//
1874+
// CommandDispatcher.submitSilent(deviceCodeFlowCommand);
1875+
// } catch (final MsalClientException e) {
1876+
// final MsalClientException clientException = new MsalClientException(
1877+
// UNKNOWN_ERROR,
1878+
// "Unexpected error while acquiring token with device code.",
1879+
// e
1880+
// );
1881+
// callback.onError(clientException);
1882+
// }
1883+
// }
1884+
18411885
public void acquireTokenWithDeviceCode(@NonNull List<String> scopes, @NonNull final DeviceCodeFlowCallback callback) {
18421886
// Create a DeviceCodeFlowCommandParameters object that takes in the desired scopes and the callback object
18431887
// Use CommandParametersAdapter

msal/src/main/java/com/microsoft/identity/client/SingleAccountPublicClientApplication.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@
5959
import com.microsoft.identity.common.adal.internal.util.StringExtensions;
6060
import com.microsoft.identity.common.crypto.AndroidAuthSdkStorageEncryptionManager;
6161
import com.microsoft.identity.common.internal.cache.SharedPreferencesFileManager;
62-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommandCallback;
6362
import com.microsoft.identity.common.internal.commands.GetCurrentAccountCommand;
6463
import com.microsoft.identity.common.internal.commands.RemoveCurrentAccountCommand;
6564
import com.microsoft.identity.common.internal.migration.TokenMigrationCallback;
6665
import com.microsoft.identity.common.java.cache.ICacheRecord;
6766
import com.microsoft.identity.common.java.commands.CommandCallback;
67+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommandCallback;
6868
import com.microsoft.identity.common.java.commands.parameters.CommandParameters;
6969
import com.microsoft.identity.common.java.commands.parameters.RemoveAccountCommandParameters;
7070
import com.microsoft.identity.common.java.controllers.BaseController;

msal/src/main/java/com/microsoft/identity/client/internal/CommandParametersAdapter.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import com.microsoft.identity.client.AcquireTokenParameters;
1010
import com.microsoft.identity.client.AcquireTokenSilentParameters;
11+
import com.microsoft.identity.client.DeviceCodeFlowParameters;
1112
import com.microsoft.identity.client.IAccount;
1213
import com.microsoft.identity.client.ITenantProfile;
1314
import com.microsoft.identity.client.MultiTenantAccount;
@@ -203,6 +204,46 @@ public static SilentTokenCommandParameters createSilentTokenCommandParameters(
203204
return commandParameters;
204205
}
205206

207+
/**
208+
* Adapter method to create DeviceCodeFlowCommandParameters from DeviceCodeFlowParameters
209+
* @param configuration PCA configuration
210+
* @param tokenCache token cache for storing results
211+
* @param parameters deviceCodeFlowParameters
212+
* @return DeviceCodeFlowCommandParameters
213+
*/
214+
public static DeviceCodeFlowCommandParameters createDeviceCodeFlowWithClaimsCommandParameters(
215+
@NonNull final PublicClientApplicationConfiguration configuration,
216+
@NonNull final OAuth2TokenCache tokenCache,
217+
@NonNull final DeviceCodeFlowParameters parameters) {
218+
219+
final String claimsRequestJson = ClaimsRequest.getJsonStringFromClaimsRequest(parameters.getClaimsRequest());
220+
221+
final Authority authority = configuration.getDefaultAuthority();
222+
223+
final AbstractAuthenticationScheme authenticationScheme = new BearerAuthenticationSchemeInternal();
224+
225+
final DeviceCodeFlowCommandParameters commandParameters = DeviceCodeFlowCommandParameters.builder()
226+
.platformComponents(AndroidPlatformComponentsFactory.createFromContext(configuration.getAppContext()))
227+
.applicationName(configuration.getAppContext().getPackageName())
228+
.applicationVersion(getPackageVersion(configuration.getAppContext()))
229+
.clientId(configuration.getClientId())
230+
.isSharedDevice(configuration.getIsSharedDevice())
231+
.redirectUri(configuration.getRedirectUri())
232+
.oAuth2TokenCache(tokenCache)
233+
.requiredBrokerProtocolVersion(configuration.getRequiredBrokerProtocolVersion())
234+
.sdkType(SdkType.MSAL)
235+
.sdkVersion(PublicClientApplication.getSdkVersion())
236+
.powerOptCheckEnabled(configuration.isPowerOptCheckForEnabled())
237+
.authenticationScheme(authenticationScheme)
238+
.scopes(new HashSet<>(parameters.getScopes()))
239+
.authority(authority)
240+
.claimsRequestJson(claimsRequestJson)
241+
.correlationId(parameters.getCorrelationId())
242+
.build();
243+
244+
return commandParameters;
245+
}
246+
206247
public static DeviceCodeFlowCommandParameters createDeviceCodeFlowCommandParameters(
207248
@NonNull final PublicClientApplicationConfiguration configuration,
208249
@NonNull final OAuth2TokenCache tokenCache,

msal/src/test/java/com/microsoft/identity/client/CommandParametersTest.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929
import androidx.test.core.app.ApplicationProvider;
3030

3131
import com.microsoft.identity.client.claims.ClaimsRequest;
32+
import com.microsoft.identity.client.claims.RequestedClaim;
3233
import com.microsoft.identity.client.claims.RequestedClaimAdditionalInformation;
3334
import com.microsoft.identity.client.internal.CommandParametersAdapter;
3435
import com.microsoft.identity.common.components.AndroidPlatformComponentsFactory;
3536
import com.microsoft.identity.common.java.cache.IAccountCredentialAdapter;
3637
import com.microsoft.identity.common.java.cache.IAccountCredentialCache;
3738
import com.microsoft.identity.common.java.cache.MsalOAuth2TokenCache;
39+
import com.microsoft.identity.common.java.commands.parameters.DeviceCodeFlowCommandParameters;
3840
import com.microsoft.identity.common.java.commands.parameters.InteractiveTokenCommandParameters;
3941
import com.microsoft.identity.common.java.commands.parameters.SilentTokenCommandParameters;
4042
import com.microsoft.identity.common.java.providers.oauth2.OAuth2TokenCache;
@@ -50,6 +52,7 @@
5052
import java.io.File;
5153
import java.util.ArrayList;
5254
import java.util.Arrays;
55+
import java.util.List;
5356
import java.util.UUID;
5457

5558
@RunWith(RobolectricTestRunner.class)
@@ -146,6 +149,30 @@ public void testAcquireTokenSilentOperationWithCorrelationId() throws ClientExce
146149
Assert.assertEquals(correlationId.toString(), commandParameters.getCorrelationId());
147150
}
148151

152+
@Test
153+
public void testDeviceCodeFlowOperationWithClaimsWithCorrelationId() throws ClientException {
154+
final UUID correlationId = UUID.randomUUID();
155+
DeviceCodeFlowCommandParameters commandParameters = CommandParametersAdapter.createDeviceCodeFlowWithClaimsCommandParameters(getConfiguration(AAD_NONE_CONFIG_FILE), getCache(), getDeviceCodeFlowParametersWithClaimsWithCorrelationId(correlationId));
156+
Assert.assertNotNull(commandParameters.getCorrelationId());
157+
Assert.assertEquals(correlationId.toString(), commandParameters.getCorrelationId());
158+
validateDeviceCodeFlowClaimsInCommandParameter(commandParameters);
159+
}
160+
161+
@Test
162+
public void testDeviceCodeFlowOperationWithClaimsWithoutCorrelationId() throws ClientException {
163+
DeviceCodeFlowCommandParameters commandParameters = CommandParametersAdapter.createDeviceCodeFlowWithClaimsCommandParameters(getConfiguration(AAD_NONE_CONFIG_FILE), getCache(), getDeviceCodeFlowParametersWithClaimsWithoutCorrelationId());
164+
Assert.assertNull(commandParameters.getCorrelationId());
165+
validateDeviceCodeFlowClaimsInCommandParameter(commandParameters);
166+
}
167+
168+
@Test
169+
public void testDeviceCodeFlowOperationWithoutClaims() throws ClientException {
170+
DeviceCodeFlowCommandParameters commandParameters = CommandParametersAdapter.createDeviceCodeFlowWithClaimsCommandParameters(getConfiguration(AAD_NONE_CONFIG_FILE), getCache(), getDeviceCodeFlowParametersWithoutClaims());
171+
Assert.assertNull(commandParameters.getCorrelationId());
172+
Assert.assertNull(commandParameters.getClaimsRequestJson());
173+
}
174+
175+
149176
private ClaimsRequest getAccessTokenClaimsRequest(@NonNull String claimName, @NonNull String claimValue) {
150177
ClaimsRequest cp1ClaimsRequest = new ClaimsRequest();
151178
RequestedClaimAdditionalInformation info = new RequestedClaimAdditionalInformation();
@@ -232,6 +259,53 @@ private AcquireTokenParameters getAcquireTokenParametersWithCorrelationId(final
232259
return parameters;
233260
}
234261

262+
private DeviceCodeFlowParameters getDeviceCodeFlowParametersWithClaimsWithCorrelationId(final UUID correlationId) {
263+
DeviceCodeFlowParameters parameters = new DeviceCodeFlowParameters.Builder()
264+
.withClaims(getDeviceCodeFlowClaimsRequest())
265+
.withScopes(new ArrayList<String>(Arrays.asList("User.Read")))
266+
.withCorrelationId(correlationId)
267+
.build();
268+
269+
return parameters;
270+
}
271+
272+
private DeviceCodeFlowParameters getDeviceCodeFlowParametersWithClaimsWithoutCorrelationId() {
273+
DeviceCodeFlowParameters parameters = new DeviceCodeFlowParameters.Builder()
274+
.withClaims(getDeviceCodeFlowClaimsRequest())
275+
.withScopes(new ArrayList<String>(Arrays.asList("User.Read")))
276+
.build();
277+
278+
return parameters;
279+
}
280+
281+
private DeviceCodeFlowParameters getDeviceCodeFlowParametersWithoutClaims() {
282+
DeviceCodeFlowParameters parameters = new DeviceCodeFlowParameters.Builder()
283+
.withScopes(new ArrayList<String>(Arrays.asList("User.Read")))
284+
.build();
285+
286+
return parameters;
287+
}
288+
289+
private ClaimsRequest getDeviceCodeFlowClaimsRequest() {
290+
RequestedClaimAdditionalInformation information = new RequestedClaimAdditionalInformation();
291+
information.setEssential(true);
292+
ClaimsRequest claimsRequest = new ClaimsRequest();
293+
claimsRequest.requestClaimInAccessToken("deviceid", information);
294+
return claimsRequest;
295+
}
296+
297+
private void validateDeviceCodeFlowClaimsInCommandParameter(DeviceCodeFlowCommandParameters deviceCodeFlowCommandParameters) {
298+
Assert.assertNotNull(deviceCodeFlowCommandParameters.getClaimsRequestJson());
299+
ClaimsRequest claimsRequest = ClaimsRequest.getClaimsRequestFromJsonString(deviceCodeFlowCommandParameters.getClaimsRequestJson());
300+
Assert.assertNotNull(claimsRequest);
301+
Assert.assertNotNull(claimsRequest.getAccessTokenClaimsRequested());
302+
RequestedClaim requestedClaim = claimsRequest.getAccessTokenClaimsRequested().get(0);
303+
Assert.assertNotNull(requestedClaim);
304+
305+
Assert.assertEquals("deviceid", requestedClaim.getName());
306+
Assert.assertTrue(requestedClaim.getAdditionalInformation().getEssential());
307+
}
308+
235309
private PublicClientApplicationConfiguration getConfiguration(String path) {
236310
return PublicClientApplicationConfigurationFactory.initializeConfiguration(mContext, getConfigFile(path));
237311
}

msal/src/test/java/com/microsoft/identity/client/e2e/shadows/ShadowDeviceCodeFlowCommandAuthError.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
// THE SOFTWARE.
2323
package com.microsoft.identity.client.e2e.shadows;
2424

25+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommand;
2526
import com.microsoft.identity.common.java.exception.ErrorStrings;
2627
import com.microsoft.identity.common.java.exception.ServiceException;
27-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommand;
2828
import com.microsoft.identity.common.java.result.AcquireTokenResult;
2929

3030
import org.robolectric.annotation.Implements;

msal/src/test/java/com/microsoft/identity/client/e2e/shadows/ShadowDeviceCodeFlowCommandSuccessful.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424

2525
import com.microsoft.identity.common.java.cache.CacheRecord;
2626
import com.microsoft.identity.common.java.cache.ICacheRecord;
27-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommand;
28-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommandCallback;
27+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommand;
28+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommandCallback;
2929
import com.microsoft.identity.common.java.dto.AccountRecord;
3030
import com.microsoft.identity.common.java.request.SdkType;
3131
import com.microsoft.identity.common.java.result.AcquireTokenResult;

msal/src/test/java/com/microsoft/identity/client/e2e/shadows/ShadowDeviceCodeFlowCommandTokenError.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
// THE SOFTWARE.
2323
package com.microsoft.identity.client.e2e.shadows;
2424

25+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommand;
26+
import com.microsoft.identity.common.java.commands.DeviceCodeFlowCommandCallback;
2527
import com.microsoft.identity.common.java.exception.ErrorStrings;
2628
import com.microsoft.identity.common.java.exception.ServiceException;
27-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommand;
28-
import com.microsoft.identity.common.internal.commands.DeviceCodeFlowCommandCallback;
2929
import com.microsoft.identity.common.java.result.AcquireTokenResult;
3030

3131
import org.robolectric.annotation.Implements;

0 commit comments

Comments
 (0)