diff options
-rw-r--r-- | Android.bp | 1 | ||||
-rw-r--r-- | jni/Android.bp | 81 | ||||
-rw-r--r-- | jni/com_android_providers_media_leveldb_LevelDBInstance.cpp | 168 | ||||
-rw-r--r-- | proguard.flags | 2 | ||||
-rw-r--r-- | src/com/android/providers/media/leveldb/LevelDBEntry.java | 39 | ||||
-rw-r--r-- | src/com/android/providers/media/leveldb/LevelDBInstance.java | 157 | ||||
-rw-r--r-- | src/com/android/providers/media/leveldb/LevelDBManager.java | 51 | ||||
-rw-r--r-- | src/com/android/providers/media/leveldb/LevelDBResult.java | 55 | ||||
-rw-r--r-- | tests/Android.bp | 5 | ||||
-rw-r--r-- | tests/src/com/android/providers/media/leveldb/LevelDBManagerTest.java | 83 |
10 files changed, 631 insertions, 11 deletions
diff --git a/Android.bp b/Android.bp index 72462ea66..1506420c8 100644 --- a/Android.bp +++ b/Android.bp @@ -49,6 +49,7 @@ android_app { jni_libs: [ "libfuse_jni", "libfuse", + "libleveldb_jni", ], resource_dirs: [ diff --git a/jni/Android.bp b/jni/Android.bp index 35721d5a6..44832efcc 100644 --- a/jni/Android.bp +++ b/jni/Android.bp @@ -30,7 +30,7 @@ cc_library_shared { "MediaProviderWrapper.cpp", "ReaddirHelper.cpp", "RedactionInfo.cpp", - "node.cpp" + "node.cpp", ], header_libs: [ @@ -44,7 +44,7 @@ cc_library_shared { "leveldb", "liblog", "libfuse", - "libandroid" + "libandroid", ], static_libs: [ @@ -73,15 +73,60 @@ cc_library_shared { min_sdk_version: "30", } +cc_library_shared { + name: "libleveldb_jni", + + srcs: [ + "com_android_providers_media_leveldb_LevelDBInstance.cpp", + ], + + header_libs: [ + "libnativehelper_header_only", + ], + + apex_available: ["com.android.mediaprovider"], + + shared_libs: [ + "leveldb", + "liblog", + "libandroid", + ], + + cflags: [ + "-Wall", + "-Werror", + "-Wno-unused-parameter", + "-Wno-unused-variable", + "-Wthread-safety", + ], + + tidy: true, + tidy_checks: [ + "-google-runtime-int", + "-performance-no-int-to-ptr", + ], + + sdk_version: "current", + stl: "c++_static", + min_sdk_version: "30", +} + cc_test { name: "fuse_node_test", - test_suites: ["device-tests", "mts-mediaprovider"], + test_suites: [ + "device-tests", + "mts-mediaprovider", + ], test_config: "fuse_node_test.xml", compile_multilib: "both", multilib: { - lib32: { suffix: "32", }, - lib64: { suffix: "64", }, + lib32: { + suffix: "32", + }, + lib64: { + suffix: "64", + }, }, srcs: [ @@ -112,13 +157,20 @@ cc_test { cc_test { name: "RedactionInfoTest", - test_suites: ["device-tests", "mts-mediaprovider"], + test_suites: [ + "device-tests", + "mts-mediaprovider", + ], test_config: "RedactionInfoTest.xml", compile_multilib: "both", multilib: { - lib32: { suffix: "32", }, - lib64: { suffix: "64", }, + lib32: { + suffix: "32", + }, + lib64: { + suffix: "64", + }, }, srcs: [ @@ -144,13 +196,20 @@ cc_test { cc_test { name: "FuseUtilsTest", - test_suites: ["device-tests", "mts-mediaprovider"], + test_suites: [ + "device-tests", + "mts-mediaprovider", + ], test_config: "FuseUtilsTest.xml", compile_multilib: "both", multilib: { - lib32: { suffix: "32", }, - lib64: { suffix: "64", }, + lib32: { + suffix: "32", + }, + lib64: { + suffix: "64", + }, }, srcs: [ diff --git a/jni/com_android_providers_media_leveldb_LevelDBInstance.cpp b/jni/com_android_providers_media_leveldb_LevelDBInstance.cpp new file mode 100644 index 000000000..8b40dd6ff --- /dev/null +++ b/jni/com_android_providers_media_leveldb_LevelDBInstance.cpp @@ -0,0 +1,168 @@ +#include <jni.h> +#include <nativehelper/scoped_utf_chars.h> + +#include "leveldb/db.h" + +#ifndef _Included_com_android_providers_media_leveldb_LevelDBInstance +#define _Included_com_android_providers_media_leveldb_LevelDBInstance +#ifdef __cplusplus +extern "C" { +#endif +#undef com_android_providers_media_leveldb_LevelDBInstance_MAX_BULK_INSERT_ENTRIES +#define com_android_providers_media_leveldb_LevelDBInstance_MAX_BULK_INSERT_ENTRIES 100L + +static std::string getStatusCode(leveldb::Status status) { + if (status.ok()) { + return "0"; + } else if (status.IsNotFound()) { + return "1"; + } else if (status.IsInvalidArgument()) { + return "2"; + } else { + return "3"; + } +} + +static jobject createLevelDBResult(JNIEnv* env, leveldb::Status status, std::string value) { + // Create the object of the class LevelDBResult + jclass levelDbResultClass = env->FindClass("com/android/providers/media/leveldb/LevelDBResult"); + jobject levelDbResultData = env->AllocObject(levelDbResultClass); + + // Get the UserData fields to be set + jfieldID codeField = env->GetFieldID(levelDbResultClass, "mCode", "Ljava/lang/String;"); + jfieldID messageField = + env->GetFieldID(levelDbResultClass, "mErrorMessage", "Ljava/lang/String;"); + jfieldID valueField = env->GetFieldID(levelDbResultClass, "mValue", "Ljava/lang/String;"); + + std::string statusCode = getStatusCode(status); + env->SetObjectField(levelDbResultData, codeField, env->NewStringUTF(statusCode.c_str())); + env->SetObjectField(levelDbResultData, messageField, + env->NewStringUTF(status.ToString().c_str())); + env->SetObjectField(levelDbResultData, valueField, env->NewStringUTF(value.c_str())); + return levelDbResultData; +} + +static leveldb::Status insertInLevelDB(JNIEnv* env, jobject obj, jlong leveldbptr, + jobject leveldbentry) { + jclass levelDbEntryClass = env->GetObjectClass(leveldbentry); + jmethodID getKeyMethodId = + env->GetMethodID(levelDbEntryClass, "getKey", "()Ljava/lang/String;"); + jmethodID getValueMethodId = + env->GetMethodID(levelDbEntryClass, "getValue", "()Ljava/lang/String;"); + + jstring key = (jstring)env->CallObjectMethod(leveldbentry, getKeyMethodId); + jstring value = (jstring)env->CallObjectMethod(leveldbentry, getValueMethodId); + ScopedUtfChars utf_chars_key(env, key); + ScopedUtfChars utf_chars_value(env, value); + leveldb::DB* leveldb = reinterpret_cast<leveldb::DB*>(leveldbptr); + leveldb::Status status; + status = leveldb->Put(leveldb::WriteOptions(), utf_chars_key.c_str(), utf_chars_value.c_str()); + return status; +} + +static jobject insert(JNIEnv* env, jobject obj, jlong leveldbptr, jobject leveldbentry) { + return createLevelDBResult(env, insertInLevelDB(env, obj, leveldbptr, leveldbentry), ""); +} + +/* + * Class: com_android_providers_media_leveldb_LevelDBInstance + * Method: nativeCreateInstance + * Signature: (Ljava/lang/String;)J + */ +JNIEXPORT jlong JNICALL + Java_com_android_providers_media_leveldb_LevelDBInstance_nativeCreateInstance( + JNIEnv* env, jclass leveldbInstanceClass, jstring path) { + ScopedUtfChars utf_chars_path(env, path); + leveldb::Options options; + options.create_if_missing = true; + leveldb::DB* leveldb; + leveldb::Status status = leveldb::DB::Open(options, utf_chars_path.c_str(), &leveldb); + if (status.ok()) { + return reinterpret_cast<jlong>(leveldb); + } else { + long val = 0; + return (jlong)val; + } +} + +/* + * Class: com_android_providers_media_leveldb_LevelDBInstance + * Method: nativeQuery + * Signature: (JLjava/lang/String;)Lcom/android/providers/media/leveldb/LevelDBResult; + */ +JNIEXPORT jobject JNICALL Java_com_android_providers_media_leveldb_LevelDBInstance_nativeQuery( + JNIEnv* env, jobject obj, jlong leveldbptr, jstring path) { + ScopedUtfChars utf_chars_path(env, path); + leveldb::DB* leveldb = reinterpret_cast<leveldb::DB*>(leveldbptr); + leveldb::Status status; + std::string value = ""; + status = leveldb->Get(leveldb::ReadOptions(), utf_chars_path.c_str(), &value); + return createLevelDBResult(env, status, value); +} + +/* + * Class: com_android_providers_media_leveldb_LevelDBInstance + * Method: nativeInsert + * Signature: (JLcom/android/providers/media/leveldb/LevelDBEntry;) + * Lcom/android/providers/media/leveldb/LevelDBResult; + */ +JNIEXPORT jobject JNICALL Java_com_android_providers_media_leveldb_LevelDBInstance_nativeInsert( + JNIEnv* env, jobject obj, jlong leveldbptr, jobject leveldbentry) { + return insert(env, obj, leveldbptr, leveldbentry); +} + +/* + * Class: com_android_providers_media_leveldb_LevelDBInstance + * Method: nativeBulkInsert + * Signature: (JLjava/util/List;)Lcom/android/providers/media/leveldb/LevelDBResult; + */ +JNIEXPORT jobject JNICALL Java_com_android_providers_media_leveldb_LevelDBInstance_nativeBulkInsert( + JNIEnv* env, jobject obj, jlong leveldbptr, jobject entries) { + // Get the class of the list + jclass listClass = env->GetObjectClass(entries); + + // Get the iterator method ID + jmethodID iteratorMethod = env->GetMethodID(listClass, "iterator", "()Ljava/util/Iterator;"); + + // Get the iterator object + jobject iterator = env->CallObjectMethod(entries, iteratorMethod); + + // Get the iterator class + jclass iteratorClass = env->GetObjectClass(iterator); + + // Get the hasNext and next method IDs + jmethodID hasNextMethod = env->GetMethodID(iteratorClass, "hasNext", "()Z"); + jmethodID nextMethod = env->GetMethodID(iteratorClass, "next", "()Ljava/lang/Object;"); + + leveldb::Status status; + + // Iterate through the list + while (env->CallBooleanMethod(iterator, hasNextMethod)) { + jobject jLevelDBEntryObject = env->CallObjectMethod(iterator, nextMethod); + leveldb::Status status = insertInLevelDB(env, obj, leveldbptr, jLevelDBEntryObject); + if (!status.ok()) { + break; + } + } + + return createLevelDBResult(env, status, ""); +} + +/* + * Class: com_android_providers_media_leveldb_LevelDBInstance + * Method: nativeDelete + * Signature: (JLjava/lang/String;)Lcom/android/providers/media/leveldb/LevelDBResult; + */ +JNIEXPORT jobject JNICALL Java_com_android_providers_media_leveldb_LevelDBInstance_nativeDelete( + JNIEnv* env, jobject obj, jlong leveldbptr, jstring key) { + ScopedUtfChars utf_chars_key(env, key); + leveldb::DB* leveldb = reinterpret_cast<leveldb::DB*>(leveldbptr); + leveldb::Status status; + status = leveldb->Delete(leveldb::WriteOptions(), utf_chars_key.c_str()); + return createLevelDBResult(env, status, ""); +} + +#ifdef __cplusplus +} +#endif +#endif diff --git a/proguard.flags b/proguard.flags index aa234cca2..e4e4c1942 100644 --- a/proguard.flags +++ b/proguard.flags @@ -15,6 +15,8 @@ -keep public final class com.android.providers.media.FileOpenResult { *; } -keep public final class com.android.providers.media.FdAccessResult { *; } -keep public class * implements com.bumptech.glide.module.GlideModule +-keep public final class com.android.providers.media.leveldb.LevelDBResult { *; } +-keep public final class com.android.providers.media.leveldb.LevelDBEntry { *; } -keep class * extends com.bumptech.glide.module.AppGlideModule { <init>(...); } diff --git a/src/com/android/providers/media/leveldb/LevelDBEntry.java b/src/com/android/providers/media/leveldb/LevelDBEntry.java new file mode 100644 index 000000000..dcc050f12 --- /dev/null +++ b/src/com/android/providers/media/leveldb/LevelDBEntry.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.leveldb; + + +/** + * Represents a leveldb entry which has a key and a value as string data type. + */ +public final class LevelDBEntry { + private final String mKey; + private final String mValue; + + public LevelDBEntry(String key, String value) { + this.mKey = key; + this.mValue = value; + } + + public String getKey() { + return mKey; + } + + public String getValue() { + return mValue; + } +} diff --git a/src/com/android/providers/media/leveldb/LevelDBInstance.java b/src/com/android/providers/media/leveldb/LevelDBInstance.java new file mode 100644 index 000000000..52586542b --- /dev/null +++ b/src/com/android/providers/media/leveldb/LevelDBInstance.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.leveldb; + +import java.io.File; +import java.util.List; + + +/** + * Represents an instance of leveldb connection. + */ +public final class LevelDBInstance { + + // Max limit of bulk insert + public static final int MAX_BULK_INSERT_ENTRIES = 100; + + private static boolean sIsLibraryLoaded = false; + + private long mNativePtr; + + private final String mLevelDBPath; + + private LevelDBInstance(long ptr, String path) { + this.mNativePtr = ptr; + this.mLevelDBPath = path; + } + + static LevelDBInstance createLevelDBInstance(String path) { + if (!sIsLibraryLoaded) { + System.loadLibrary("leveldb_jni"); + sIsLibraryLoaded = true; + } + + long ptr = nativeCreateInstance(path); + if (ptr == 0) { + throw new IllegalStateException("Leveldb connection is missing"); + } + + return new LevelDBInstance(ptr, path); + } + + /** + * Returns path of leveldb file + */ + public String getLevelDBPath() { + return mLevelDBPath; + } + + /** + * Fetch value for given key from leveldb + * + * @param key for entry + */ + public LevelDBResult query(String key) { + synchronized (this) { + if (mNativePtr == 0) { + throw new IllegalStateException("Leveldb connection is missing"); + } + + return nativeQuery(mNativePtr, key); + } + } + + /** + * Inserts key,value entry in leveldb. + * + * @param levelDbEntry contains key and value + */ + public LevelDBResult insert(LevelDBEntry levelDbEntry) { + synchronized (this) { + if (mNativePtr == 0) { + throw new IllegalStateException("Leveldb connection is missing"); + } + + return nativeInsert(mNativePtr, levelDbEntry); + } + } + + /** + * Inserts key,value entry list in leveldb. + * + * @param entryList contains list of LevelDbEntry + * @throws java.lang.IllegalArgumentException if entries size is zero or greater than 1000. + */ + public LevelDBResult bulkInsert(List<LevelDBEntry> entryList) { + synchronized (this) { + if (entryList == null || entryList.size() == 0) { + throw new IllegalArgumentException("No entries provided to insert"); + } + + if (entryList.size() > MAX_BULK_INSERT_ENTRIES) { + throw new IllegalArgumentException( + "Entry size is greater than max size: " + MAX_BULK_INSERT_ENTRIES); + } + + if (mNativePtr == 0) { + throw new IllegalStateException("Leveldb connection is missing"); + } + + return nativeBulkInsert(mNativePtr, entryList); + } + } + + /** + * Deletes entry for given key in leveldb. + * + * @param key to be deleted + */ + public LevelDBResult delete(String key) { + synchronized (this) { + if (mNativePtr == 0) { + throw new IllegalStateException("Leveldb connection is missing"); + } + + return nativeDelete(mNativePtr, key); + } + } + + /** + * Deletes entry for given key in leveldb. + * + */ + public void deleteInstance() { + synchronized (this) { + if (mNativePtr == 0) { + throw new IllegalStateException("Leveldb connection is missing"); + } + + mNativePtr = 0; + new File(getLevelDBPath()).delete(); + } + } + + private static native long nativeCreateInstance(String path); + + private native LevelDBResult nativeQuery(long nativePtr, String key); + + private native LevelDBResult nativeInsert(long nativePtr, LevelDBEntry levelDbEntry); + + private native LevelDBResult nativeBulkInsert(long nativePtr, List<LevelDBEntry> entryList); + + private native LevelDBResult nativeDelete(long nativePtr, String key); +} diff --git a/src/com/android/providers/media/leveldb/LevelDBManager.java b/src/com/android/providers/media/leveldb/LevelDBManager.java new file mode 100644 index 000000000..7c75c6667 --- /dev/null +++ b/src/com/android/providers/media/leveldb/LevelDBManager.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.leveldb; + +import com.google.common.base.Ascii; + +import java.util.HashMap; +import java.util.Map; + +/** + * Creates and manages connections to LevelDB. + */ +public final class LevelDBManager { + private static final Map<String, LevelDBInstance> INSTANCES = new HashMap<>(); + + private static final Object sLockObject = new Object(); + + private LevelDBManager() {} + + /** + * Creates leveldb instance. If already exists, returns a reference to it. + * + * @param path on which instance needs to be created + */ + public static LevelDBInstance getInstance(String path) { + synchronized (sLockObject) { + path = Ascii.toLowerCase(path.trim()); + if (INSTANCES.containsKey(path)) { + return INSTANCES.get(path); + } + + LevelDBInstance instance = LevelDBInstance.createLevelDBInstance(path); + INSTANCES.put(path, instance); + return instance; + } + } +} diff --git a/src/com/android/providers/media/leveldb/LevelDBResult.java b/src/com/android/providers/media/leveldb/LevelDBResult.java new file mode 100644 index 000000000..56cef439b --- /dev/null +++ b/src/com/android/providers/media/leveldb/LevelDBResult.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.leveldb; + +/** + * Container class for retrieving leveldb operation's result and status. + */ +public final class LevelDBResult { + + private static final String SUCCESS_CODE = "0"; + private static final String NOT_FOUND_CODE = "1"; + + private String mCode; + private String mErrorMessage; + + // May be empty for update operations + private String mValue; + + public LevelDBResult() { + } + + public String getCode() { + return mCode; + } + + public String getErrorMessage() { + return mErrorMessage; + } + + public String getValue() { + return mValue; + } + + public boolean isSuccess() { + return SUCCESS_CODE.equals(mCode); + } + + public boolean isNotFound() { + return NOT_FOUND_CODE.equals(mCode); + } +} diff --git a/tests/Android.bp b/tests/Android.bp index e3b267ca6..47638cd19 100644 --- a/tests/Android.bp +++ b/tests/Android.bp @@ -171,6 +171,11 @@ android_test { "framework-mediaprovider.impl", ], + jni_libs: [ + // Needed to run LevelDBManagerTest + "libleveldb_jni", + ], + static_libs: [ "androidx.appcompat_appcompat", "modules-utils-backgroundthread", diff --git a/tests/src/com/android/providers/media/leveldb/LevelDBManagerTest.java b/tests/src/com/android/providers/media/leveldb/LevelDBManagerTest.java new file mode 100644 index 000000000..f684e8ce5 --- /dev/null +++ b/tests/src/com/android/providers/media/leveldb/LevelDBManagerTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.leveldb; + + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@RunWith(AndroidJUnit4.class) +public class LevelDBManagerTest { + + private static final String SUCCESS_CODE = "0"; + + @Test + public void testLevelDbOperations() { + final Context context = InstrumentationRegistry.getInstrumentation().getContext(); + String levelDBFile = "test-leveldb"; + final String levelDBPath = context.getFilesDir().getPath() + "/" + levelDBFile; + LevelDBInstance levelDBInstance = LevelDBManager.getInstance(levelDBPath); + LevelDBResult levelDBResult; + try { + + List<String> fileNames = Arrays.stream(context.getFilesDir().listFiles()).map( + File::getName).collect(Collectors.toList()); + assertThat(fileNames).contains(levelDBFile); + + levelDBResult = levelDBInstance.insert(new LevelDBEntry("a", "1")); + verifySuccessResult(levelDBResult); + + levelDBResult = levelDBInstance.query("a"); + verifySuccessResult(levelDBResult); + assertThat(levelDBResult.getValue()).isEqualTo("1"); + + levelDBResult = levelDBInstance.delete("b"); + verifySuccessResult(levelDBResult); + + levelDBResult = levelDBInstance.bulkInsert( + Arrays.asList(new LevelDBEntry("c", "3"), new LevelDBEntry("d", "4"))); + verifySuccessResult(levelDBResult); + + assertThat(levelDBInstance.query("c").getValue()).isEqualTo("3"); + assertThat(levelDBInstance.query("d").getValue()).isEqualTo("4"); + assertThat(levelDBInstance.query("b").getValue()).isEqualTo(""); + assertThat(levelDBInstance.query("a").getValue()).isEqualTo("1"); + + } finally { + // Deletes leveldb file + levelDBInstance.deleteInstance(); + } + } + + private void verifySuccessResult(LevelDBResult levelDBResult) { + assertThat(levelDBResult).isNotNull(); + assertThat(levelDBResult.getCode()).isNotNull(); + assertThat(levelDBResult.getCode()).isEqualTo(SUCCESS_CODE); + } +} |