From 3f241f8d8eb50bb2bffc747615c6c9d420d5e38b Mon Sep 17 00:00:00 2001 From: ssangamesh Date: Tue, 10 Jun 2025 09:36:28 +0000 Subject: [PATCH 1/4] Android: worked on lint and build warnings --- .../grpc/android/AndroidChannelBuilder.java | 2 -- .../grpc/binder/AndroidComponentAddress.java | 2 ++ .../java/io/grpc/binder/SecurityPolicies.java | 2 -- .../grpc/binder/internal/ServiceBinding.java | 23 +++++++++++-------- lint.xml | 4 ++++ 5 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 lint.xml diff --git a/android/src/main/java/io/grpc/android/AndroidChannelBuilder.java b/android/src/main/java/io/grpc/android/AndroidChannelBuilder.java index e56ce5fc405..54b38bc3bd3 100644 --- a/android/src/main/java/io/grpc/android/AndroidChannelBuilder.java +++ b/android/src/main/java/io/grpc/android/AndroidChannelBuilder.java @@ -217,7 +217,6 @@ private void configureNetworkMonitoring() { connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback); unregisterRunnable = new Runnable() { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public void run() { connectivityManager.unregisterNetworkCallback(defaultNetworkCallback); @@ -231,7 +230,6 @@ public void run() { context.registerReceiver(networkReceiver, networkIntentFilter); unregisterRunnable = new Runnable() { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public void run() { context.unregisterReceiver(networkReceiver); diff --git a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java index c4c17bb2cef..ed810be60cb 100644 --- a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java +++ b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java @@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; +import android.annotation.SuppressLint; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -165,6 +166,7 @@ public Intent asBindIntent() { * *

See {@link Intent#URI_ANDROID_APP_SCHEME} for details. */ + @SuppressLint("InlinedApi") public String asAndroidAppUri() { Intent intentForUri = bindIntent; if (intentForUri.getPackage() == null) { diff --git a/binder/src/main/java/io/grpc/binder/SecurityPolicies.java b/binder/src/main/java/io/grpc/binder/SecurityPolicies.java index 05e8c43da79..c0f6fe81989 100644 --- a/binder/src/main/java/io/grpc/binder/SecurityPolicies.java +++ b/binder/src/main/java/io/grpc/binder/SecurityPolicies.java @@ -184,7 +184,6 @@ public Status checkAuthorization(int uid) { * Creates {@link SecurityPolicy} which checks if the app is a device owner app. See {@link * DevicePolicyManager}. */ - @RequiresApi(18) public static io.grpc.binder.SecurityPolicy isDeviceOwner(Context applicationContext) { DevicePolicyManager devicePolicyManager = (DevicePolicyManager) applicationContext.getSystemService(Context.DEVICE_POLICY_SERVICE); @@ -199,7 +198,6 @@ public static io.grpc.binder.SecurityPolicy isDeviceOwner(Context applicationCon * Creates {@link SecurityPolicy} which checks if the app is a profile owner app. See {@link * DevicePolicyManager}. */ - @RequiresApi(21) public static SecurityPolicy isProfileOwner(Context applicationContext) { DevicePolicyManager devicePolicyManager = (DevicePolicyManager) applicationContext.getSystemService(Context.DEVICE_POLICY_SERVICE); diff --git a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java index ee171140045..ffc79f57c11 100644 --- a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java +++ b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java @@ -23,6 +23,7 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.os.Build; import android.os.IBinder; import android.os.UserHandle; import androidx.annotation.AnyThread; @@ -183,18 +184,22 @@ private static Status bindInternal( bindResult = context.bindService(bindIntent, conn, flags); break; case BIND_SERVICE_AS_USER: - bindResult = context.bindServiceAsUser(bindIntent, conn, flags, targetUserHandle); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + bindResult = context.bindServiceAsUser(bindIntent, conn, flags, targetUserHandle); + } break; case DEVICE_POLICY_BIND_SEVICE_ADMIN: DevicePolicyManager devicePolicyManager = - (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE); - bindResult = - devicePolicyManager.bindDeviceAdminServiceAsUser( - channelCredentials.getDevicePolicyAdminComponentName(), - bindIntent, - conn, - flags, - targetUserHandle); + (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + bindResult = + devicePolicyManager.bindDeviceAdminServiceAsUser( + channelCredentials.getDevicePolicyAdminComponentName(), + bindIntent, + conn, + flags, + targetUserHandle); + } break; } if (!bindResult) { diff --git a/lint.xml b/lint.xml new file mode 100644 index 00000000000..9dcca2a820a --- /dev/null +++ b/lint.xml @@ -0,0 +1,4 @@ + + + + From 1655cb416ee86fb3cf6cbc8df13b38440872a325 Mon Sep 17 00:00:00 2001 From: ssangamesh Date: Fri, 11 Jul 2025 04:43:19 +0000 Subject: [PATCH 2/4] Android: fixed internal review points --- .../grpc/binder/AndroidComponentAddress.java | 8 +- .../grpc/binder/internal/ServiceBinding.java | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java index af300c9d7a8..327be3cdbcb 100644 --- a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java +++ b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java @@ -17,6 +17,7 @@ package io.grpc.binder; import static android.content.Intent.URI_ANDROID_APP_SCHEME; +import static android.content.Intent.URI_INTENT_SCHEME; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; @@ -24,6 +25,7 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.UserHandle; import com.google.common.base.Objects; import io.grpc.ExperimentalApi; @@ -166,7 +168,6 @@ public Intent asBindIntent() { * *

See {@link Intent#URI_ANDROID_APP_SCHEME} for details. */ - @SuppressLint("InlinedApi") public String asAndroidAppUri() { Intent intentForUri = bindIntent; if (intentForUri.getPackage() == null) { @@ -174,7 +175,10 @@ public String asAndroidAppUri() { // factory methods. Oddly, a ComponentName is not enough. intentForUri = intentForUri.cloneFilter().setPackage(getComponent().getPackageName()); } - return intentForUri.toUri(URI_ANDROID_APP_SCHEME); + return intentForUri.toUri( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 + ? URI_ANDROID_APP_SCHEME + : URI_INTENT_SCHEME); } @Override diff --git a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java index ffc79f57c11..eadfb8bb02b 100644 --- a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java +++ b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java @@ -23,15 +23,22 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; import android.os.Build; import android.os.IBinder; import android.os.UserHandle; import androidx.annotation.AnyThread; import androidx.annotation.MainThread; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.VerifyException; import com.google.errorprone.annotations.concurrent.GuardedBy; import io.grpc.Status; +import io.grpc.StatusException; import io.grpc.binder.BinderChannelCredentials; +import java.lang.reflect.Method; +import java.util.List; import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; @@ -86,6 +93,8 @@ public String methodName() { private final Observer observer; private final Executor mainThreadExecutor; + private static volatile Method queryIntentServicesAsUserMethod; + @GuardedBy("this") private State state; @@ -252,6 +261,71 @@ void unbindInternal(Status reason) { } } + // Sadly the PackageManager#resolveServiceAsUser() API we need isn't part of the SDK or even a + // @SystemApi as of this writing. Modern Android prevents even system apps from calling it, by any + // means (https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces). + // So instead we call queryIntentServicesAsUser(), which does more than we need but *is* a + // @SystemApi in all the SDK versions where we support cross-user Channels. + @Nullable + private static ResolveInfo resolveServiceAsUser( + PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { + List results = + queryIntentServicesAsUser(packageManager, intent, flags, targetUserHandle); + // The first query result is "what would be returned by resolveService", per the javadoc. + return (results != null && !results.isEmpty()) ? results.get(0) : null; + } + + // The cross-user Channel feature requires the client to be a system app so we assume @SystemApi + // queryIntentServicesAsUser() is visible to us at runtime. It would be visible at build time too, + // if our host system app were written to call it directly. We only have to use reflection here + // because grpc-java is a library built outside the Android source tree where the compiler can't + // see the "non-SDK" @SystemApis that we need. + @Nullable + @SuppressWarnings("unchecked") // Safe by PackageManager#queryIntentServicesAsUser spec in AOSP. + private static List queryIntentServicesAsUser( + PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { + try { + if (queryIntentServicesAsUserMethod == null) { + synchronized (ServiceBinding.class) { + if (queryIntentServicesAsUserMethod == null) { + queryIntentServicesAsUserMethod = + PackageManager.class.getMethod( + "queryIntentServicesAsUser", Intent.class, int.class, UserHandle.class); + } + } + } + return (List) + queryIntentServicesAsUserMethod.invoke(packageManager, intent, flags, targetUserHandle); + } catch (ReflectiveOperationException e) { + throw new VerifyException(e); + } + } + + @AnyThread + @Override + public ServiceInfo resolve() throws StatusException { + checkState(sourceContext != null); + PackageManager packageManager = sourceContext.getPackageManager(); + int flags = 0; + if (Build.VERSION.SDK_INT >= 29) { + // Filter out non-'directBootAware' s when 'targetUserHandle' is locked. Here's why: + // Callers want 'bindIntent' to #resolve() to the same thing a follow-up call to #bind() will. + // But bindService() *always* ignores services that can't presently be created for lack of + // 'directBootAware'-ness. This flag explicitly tells resolveService() to act the same way. + flags |= PackageManager.MATCH_DIRECT_BOOT_AUTO; + } + ResolveInfo resolveInfo = + targetUserHandle != null + ? resolveServiceAsUser(packageManager, bindIntent, flags, targetUserHandle) + : packageManager.resolveService(bindIntent, flags); + if (resolveInfo == null) { + throw Status.UNIMPLEMENTED // Same status code as when bindService() returns false. + .withDescription("resolveService(" + bindIntent + " / " + targetUserHandle + ") was null") + .asException(); + } + return resolveInfo.serviceInfo; + } + @MainThread private void clearReferences() { sourceContext = null; From 356f3457c0bda910c43845457bdeeb10619feab4 Mon Sep 17 00:00:00 2001 From: ssangamesh Date: Mon, 14 Jul 2025 03:42:46 +0000 Subject: [PATCH 3/4] Android: worked on lint and build warnings --- .../grpc/binder/AndroidComponentAddress.java | 6 ++--- .../grpc/binder/internal/ServiceBinding.java | 25 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java index 327be3cdbcb..cd8c62262ef 100644 --- a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java +++ b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java @@ -176,9 +176,9 @@ public String asAndroidAppUri() { intentForUri = intentForUri.cloneFilter().setPackage(getComponent().getPackageName()); } return intentForUri.toUri( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 - ? URI_ANDROID_APP_SCHEME - : URI_INTENT_SCHEME); + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 + ? URI_ANDROID_APP_SCHEME + : URI_INTENT_SCHEME); } @Override diff --git a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java index eadfb8bb02b..46ef10a7d16 100644 --- a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java +++ b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java @@ -201,8 +201,7 @@ private static Status bindInternal( DevicePolicyManager devicePolicyManager = (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - bindResult = - devicePolicyManager.bindDeviceAdminServiceAsUser( + bindResult = devicePolicyManager.bindDeviceAdminServiceAsUser( channelCredentials.getDevicePolicyAdminComponentName(), bindIntent, conn, @@ -268,9 +267,9 @@ void unbindInternal(Status reason) { // @SystemApi in all the SDK versions where we support cross-user Channels. @Nullable private static ResolveInfo resolveServiceAsUser( - PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { + PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { List results = - queryIntentServicesAsUser(packageManager, intent, flags, targetUserHandle); + queryIntentServicesAsUser(packageManager, intent, flags, targetUserHandle); // The first query result is "what would be returned by resolveService", per the javadoc. return (results != null && !results.isEmpty()) ? results.get(0) : null; } @@ -283,19 +282,19 @@ private static ResolveInfo resolveServiceAsUser( @Nullable @SuppressWarnings("unchecked") // Safe by PackageManager#queryIntentServicesAsUser spec in AOSP. private static List queryIntentServicesAsUser( - PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { + PackageManager packageManager, Intent intent, int flags, UserHandle targetUserHandle) { try { if (queryIntentServicesAsUserMethod == null) { synchronized (ServiceBinding.class) { if (queryIntentServicesAsUserMethod == null) { queryIntentServicesAsUserMethod = - PackageManager.class.getMethod( - "queryIntentServicesAsUser", Intent.class, int.class, UserHandle.class); + PackageManager.class.getMethod( + "queryIntentServicesAsUser", Intent.class, int.class, UserHandle.class); } } } return (List) - queryIntentServicesAsUserMethod.invoke(packageManager, intent, flags, targetUserHandle); + queryIntentServicesAsUserMethod.invoke(packageManager, intent, flags, targetUserHandle); } catch (ReflectiveOperationException e) { throw new VerifyException(e); } @@ -315,13 +314,13 @@ public ServiceInfo resolve() throws StatusException { flags |= PackageManager.MATCH_DIRECT_BOOT_AUTO; } ResolveInfo resolveInfo = - targetUserHandle != null - ? resolveServiceAsUser(packageManager, bindIntent, flags, targetUserHandle) - : packageManager.resolveService(bindIntent, flags); + targetUserHandle != null + ? resolveServiceAsUser(packageManager, bindIntent, flags, targetUserHandle) + : packageManager.resolveService(bindIntent, flags); if (resolveInfo == null) { throw Status.UNIMPLEMENTED // Same status code as when bindService() returns false. - .withDescription("resolveService(" + bindIntent + " / " + targetUserHandle + ") was null") - .asException(); + .withDescription("resolveService(" + bindIntent + " / " + targetUserHandle + ") was null") + .asException(); } return resolveInfo.serviceInfo; } From 637fc049cbd48884c7199397e8066cbe2573fb47 Mon Sep 17 00:00:00 2001 From: Sangamesh1997 Date: Wed, 6 Aug 2025 07:22:25 +0000 Subject: [PATCH 4/4] Android : Worked on review comments (Binder) --- android/build.gradle | 2 +- binder/build.gradle | 2 +- .../grpc/binder/AndroidComponentAddress.java | 4 +- .../grpc/binder/internal/ServiceBinding.java | 8 +++- .../binder/internal/ServiceBindingTest.java | 43 +++++++++++++++++++ cronet/build.gradle | 2 +- lint.xml | 4 ++ 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 723be882982..d0ae3b9c886 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -14,7 +14,7 @@ android { } compileSdkVersion 34 defaultConfig { - minSdkVersion 21 + minSdkVersion 22 targetSdkVersion 33 versionCode 1 versionName "1.0" diff --git a/binder/build.gradle b/binder/build.gradle index dc9df6b04de..d1302ddfed0 100644 --- a/binder/build.gradle +++ b/binder/build.gradle @@ -13,7 +13,7 @@ android { targetCompatibility 1.8 } defaultConfig { - minSdkVersion 21 + minSdkVersion 22 targetSdkVersion 33 versionCode 1 versionName "1.0" diff --git a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java index cd8c62262ef..21781cd4ad3 100644 --- a/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java +++ b/binder/src/main/java/io/grpc/binder/AndroidComponentAddress.java @@ -176,9 +176,7 @@ public String asAndroidAppUri() { intentForUri = intentForUri.cloneFilter().setPackage(getComponent().getPackageName()); } return intentForUri.toUri( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 - ? URI_ANDROID_APP_SCHEME - : URI_INTENT_SCHEME); + URI_ANDROID_APP_SCHEME); } @Override diff --git a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java index 46ef10a7d16..d195cbf7484 100644 --- a/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java +++ b/binder/src/main/java/io/grpc/binder/internal/ServiceBinding.java @@ -195,18 +195,24 @@ private static Status bindInternal( case BIND_SERVICE_AS_USER: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { bindResult = context.bindServiceAsUser(bindIntent, conn, flags, targetUserHandle); + } else { + return Status.INTERNAL. + withDescription("Cross user Channel requires Android R+"); } break; case DEVICE_POLICY_BIND_SEVICE_ADMIN: DevicePolicyManager devicePolicyManager = (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { bindResult = devicePolicyManager.bindDeviceAdminServiceAsUser( channelCredentials.getDevicePolicyAdminComponentName(), bindIntent, conn, flags, targetUserHandle); + } else { + return Status.INTERNAL. + withDescription("Device policy admin binding requires Android R+"); } break; } diff --git a/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java b/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java index bd51c522d15..4f19ffafb94 100644 --- a/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java +++ b/binder/src/test/java/io/grpc/binder/internal/ServiceBindingTest.java @@ -21,6 +21,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; import static org.robolectric.Shadows.shadowOf; import android.app.Application; @@ -29,6 +30,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ServiceInfo; +import android.os.Build; import android.os.IBinder; import android.os.Parcel; import android.os.UserHandle; @@ -327,6 +329,47 @@ public void testResolveNonExistentServiceWithTargetUserThrows() throws Exception assertThat(statusException.getStatus().getDescription()).contains("12345"); } + @Test + @Config(sdk = 30) + public void testBindService_doesNotThrowInternalErrorWhenSDkAtLeastR() { + UserHandle userHandle = mock(UserHandle.class); + binding = newBuilder().setTargetUserHandle(userHandle).build(); + binding.bind(); + shadowOf(getMainLooper()).idle(); + + assertThat(Build.VERSION.SDK_INT).isEqualTo(Build.VERSION_CODES.R); + assertThat(observer.unboundReason).isNull(); + } + + @Test + @Config(sdk = 28) + public void testBindServiceAsUser_returnsErrorWhenSDkBelowR() { + UserHandle userHandle = mock(UserHandle.class); + binding = newBuilder().setTargetUserHandle(userHandle).build(); + binding.bind(); + shadowOf(getMainLooper()).idle(); + + assertThat(observer.unboundReason.getCode()).isEqualTo(Code.INTERNAL); + assertThat(observer.unboundReason.getDescription()).isEqualTo("Cross user Channel requires Android R+"); + } + + @Test + @Config(sdk = 28) + public void testDevicePolicyBlind_returnsErrorWhenSDkBelowR() { + String deviceAdminClassName = "DevicePolicyAdmin"; + ComponentName adminComponent = new ComponentName(appContext, deviceAdminClassName); + allowBindDeviceAdminForUser(appContext, adminComponent,10); + binding = newBuilder() + .setTargetUserHandle(UserHandle.getUserHandleForUid(10)) + .setChannelCredentials(BinderChannelCredentials.forDevicePolicyAdmin(adminComponent)) + .build(); + binding.bind(); + shadowOf(getMainLooper()).idle(); + + assertThat(observer.unboundReason.getCode()).isEqualTo(Code.INTERNAL); + assertThat(observer.unboundReason.getDescription()).isEqualTo("Device policy admin binding requires Android R+"); + } + @Test @Config(sdk = 30) public void testBindWithDeviceAdmin() throws Exception { diff --git a/cronet/build.gradle b/cronet/build.gradle index 3cc86201298..0715b4129bf 100644 --- a/cronet/build.gradle +++ b/cronet/build.gradle @@ -14,7 +14,7 @@ android { namespace = 'io.grpc.cronet' compileSdkVersion 33 defaultConfig { - minSdkVersion 21 + minSdkVersion 22 targetSdkVersion 33 versionCode 1 versionName "1.0" diff --git a/lint.xml b/lint.xml index 9dcca2a820a..93e2f603108 100644 --- a/lint.xml +++ b/lint.xml @@ -1,4 +1,8 @@ +