diff --git a/application-client/openvidu-android/.gitignore b/application-client/openvidu-android/.gitignore new file mode 100644 index 00000000..226d61ce --- /dev/null +++ b/application-client/openvidu-android/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.idea/deploymentTargetSelector.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/application-client/openvidu-android/.idea/.gitignore b/application-client/openvidu-android/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/application-client/openvidu-android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/application-client/openvidu-android/.idea/.name b/application-client/openvidu-android/.idea/.name new file mode 100644 index 00000000..a4b2ad72 --- /dev/null +++ b/application-client/openvidu-android/.idea/.name @@ -0,0 +1 @@ +Basic Android \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/compiler.xml b/application-client/openvidu-android/.idea/compiler.xml new file mode 100644 index 00000000..b589d56e --- /dev/null +++ b/application-client/openvidu-android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/gradle.xml b/application-client/openvidu-android/.idea/gradle.xml new file mode 100644 index 00000000..0897082f --- /dev/null +++ b/application-client/openvidu-android/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/kotlinc.xml b/application-client/openvidu-android/.idea/kotlinc.xml new file mode 100644 index 00000000..fdf8d994 --- /dev/null +++ b/application-client/openvidu-android/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/migrations.xml b/application-client/openvidu-android/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/application-client/openvidu-android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/misc.xml b/application-client/openvidu-android/.idea/misc.xml new file mode 100644 index 00000000..0ad17cbd --- /dev/null +++ b/application-client/openvidu-android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/other.xml b/application-client/openvidu-android/.idea/other.xml new file mode 100644 index 00000000..0d3a1fbb --- /dev/null +++ b/application-client/openvidu-android/.idea/other.xml @@ -0,0 +1,263 @@ + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/.idea/vcs.xml b/application-client/openvidu-android/.idea/vcs.xml new file mode 100644 index 00000000..b2bdec2d --- /dev/null +++ b/application-client/openvidu-android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/README.md b/application-client/openvidu-android/README.md new file mode 100644 index 00000000..8754147d --- /dev/null +++ b/application-client/openvidu-android/README.md @@ -0,0 +1,21 @@ +# Basic Android + +Basic client application built for Android using Kotlin. It internally uses [livekit-client-sdk-android](https://docs.livekit.io/client-sdk-js/). + +For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/application-client/android/). + +## Prerequisites + +- [Android Studio](https://developer.android.com/studio) + +## Run + +1. Download repository + +```bash +git clone https://github.com/OpenVidu/openvidu-livekit-tutorials.git +``` + +2. Open Android Studio and import the project `openvidu-livekit-tutorials/application-client/openvidu-android` + +3. Run the application in an emulator or a physical device by clicking the `Run` button in Android Studio. Check out the [official documentation](https://developer.android.com/studio/run) for further information. diff --git a/application-client/openvidu-android/app/.gitignore b/application-client/openvidu-android/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/application-client/openvidu-android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/application-client/openvidu-android/app/build.gradle.kts b/application-client/openvidu-android/app/build.gradle.kts new file mode 100644 index 00000000..237e65f5 --- /dev/null +++ b/application-client/openvidu-android/app/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.jetbrains.kotlin.plugin.serialization) +} + +android { + namespace = "io.openvidu.android" + compileSdk = 34 + + defaultConfig { + applicationId = "io.openvidu.android" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation(libs.livekit.android) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.annotation) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/application-client/openvidu-android/app/proguard-rules.pro b/application-client/openvidu-android/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/application-client/openvidu-android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/androidTest/java/io/openvidu/android/ExampleInstrumentedTest.kt b/application-client/openvidu-android/app/src/androidTest/java/io/openvidu/android/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..a91e9916 --- /dev/null +++ b/application-client/openvidu-android/app/src/androidTest/java/io/openvidu/android/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package io.openvidu.android + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("io.openvidu.android", appContext.packageName) + } +} \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/AndroidManifest.xml b/application-client/openvidu-android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5eab0157 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/ic_launcher-playstore.png b/application-client/openvidu-android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..68e0e28c Binary files /dev/null and b/application-client/openvidu-android/app/src/main/ic_launcher-playstore.png differ diff --git a/application-client/openvidu-android/app/src/main/java/io/openvidu/android/MainActivity.kt b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/MainActivity.kt new file mode 100644 index 00000000..249de477 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/MainActivity.kt @@ -0,0 +1,80 @@ +package io.openvidu.android + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import io.openvidu.android.databinding.ActivityMainBinding +import io.openvidu.android.databinding.DialogSettingsBinding + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + + // Configure this variables with correct URLs depending on your deployment + private var applicationServerUrl = "https://{YOUR-LAN-IP}.openvidu-local.dev:6443/" + private var livekitUrl = "wss://{YOUR-LAN-IP}.openvidu-local.dev:7443/" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.participantName.setText("Participant%d".format((1..100).random())) + + binding.joinButton.setOnClickListener { + navigateToRoomLayoutActivity() + } + + binding.settingsButton.setOnClickListener { + showSettingsDialog() + } + } + + private fun navigateToRoomLayoutActivity() { + binding.joinButton.isEnabled = false + + val participantName = binding.participantName.text.toString() + val roomName = binding.roomName.text.toString() + + if (participantName.isNotEmpty() && roomName.isNotEmpty()) { + val intent = Intent(this, RoomLayoutActivity::class.java) + intent.putExtra("participantName", participantName) + intent.putExtra("roomName", roomName) + intent.putExtra("serverUrl", applicationServerUrl) + intent.putExtra("livekitUrl", livekitUrl) + startActivity(intent) + } else { + Toast.makeText(this, "Please fill in all fields", Toast.LENGTH_SHORT).show() + } + + binding.joinButton.isEnabled = true + } + + /** + * This dialog allows to change the LiveKit URL and the application server URL + * from the application itself. This is useful for development purposes. + */ + private fun showSettingsDialog() { + val dialogBinding = DialogSettingsBinding.inflate(LayoutInflater.from(this)) + + dialogBinding.serverUrl.setText(applicationServerUrl) + dialogBinding.livekitUrl.setText(livekitUrl) + + val builder = AlertDialog.Builder(this) + builder.setTitle("Configure URLs") + .setView(dialogBinding.root) + .setPositiveButton("Save") { dialog, _ -> + applicationServerUrl = dialogBinding.serverUrl.text.toString() + livekitUrl = dialogBinding.livekitUrl.text.toString() + dialog.dismiss() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + + val dialog = builder.create() + dialog.show() + } +} \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/java/io/openvidu/android/ParticipantAdapter.kt b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/ParticipantAdapter.kt new file mode 100644 index 00000000..36ef2c6d --- /dev/null +++ b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/ParticipantAdapter.kt @@ -0,0 +1,23 @@ +package io.openvidu.android + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.livekit.android.room.Room +import io.openvidu.android.databinding.ParticipantItemBinding + +class ParticipantAdapter(private val participantTracks: List, private val room: Room) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ParticipantViewHolder = + ParticipantViewHolder( + ParticipantItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun onBindViewHolder(holder: ParticipantViewHolder, position: Int) { + val trackInfo = participantTracks[position] + holder.render(trackInfo, room) + } + + override fun getItemCount(): Int = participantTracks.size +} \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/java/io/openvidu/android/ParticipantViewHolder.kt b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/ParticipantViewHolder.kt new file mode 100644 index 00000000..3c0cbd62 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/ParticipantViewHolder.kt @@ -0,0 +1,29 @@ +package io.openvidu.android + +import androidx.recyclerview.widget.RecyclerView +import io.livekit.android.room.Room +import io.openvidu.android.databinding.ParticipantItemBinding + +class ParticipantViewHolder(private val binding: ParticipantItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + private var used = false + + fun render(trackInfo: TrackInfo, room: Room) { + val participantIdentity = if (trackInfo.isLocal) { + trackInfo.participantIdentity + " (You)" + } else { + trackInfo.participantIdentity + } + + binding.identity.text = participantIdentity + + // Only initialize the renderer once + if (!used) { + room.initVideoRenderer(binding.renderer) + used = true + } + + trackInfo.track.addRenderer(binding.renderer) + } +} diff --git a/application-client/openvidu-android/app/src/main/java/io/openvidu/android/RoomLayoutActivity.kt b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/RoomLayoutActivity.kt new file mode 100644 index 00000000..f66dd9c0 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/java/io/openvidu/android/RoomLayoutActivity.kt @@ -0,0 +1,242 @@ +package io.openvidu.android + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import io.livekit.android.LiveKit +import io.livekit.android.events.RoomEvent +import io.livekit.android.events.collect +import io.livekit.android.room.Room +import io.livekit.android.room.track.VideoTrack +import io.livekit.android.util.flow +import io.openvidu.android.databinding.ActivityRoomLayoutBinding +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +data class TrackInfo( + val track: VideoTrack, + val participantIdentity: String, + val isLocal: Boolean = false +) + +class RoomLayoutActivity : AppCompatActivity() { + private lateinit var binding: ActivityRoomLayoutBinding + private lateinit var participantAdapter: ParticipantAdapter + + private lateinit var APPLICATION_SERVER_URL: String + private lateinit var LIVEKIT_URL: String + + private lateinit var room: Room + private val participantTracks: MutableList = mutableListOf() + + private val client = HttpClient(CIO) { + expectSuccess = true + install(ContentNegotiation) { + json() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityRoomLayoutBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.loader.visibility = View.VISIBLE + binding.leaveButton.setOnClickListener { + leaveRoom() + } + + APPLICATION_SERVER_URL = intent.getStringExtra("serverUrl") ?: "" + LIVEKIT_URL = intent.getStringExtra("livekitUrl") ?: "" + + // Create Room object + room = LiveKit.create(applicationContext) + + initRecyclerView() + + // Check for audio and camera permissions before connecting to the room + requestNeededPermissions { connectToRoom() } + } + + private fun initRecyclerView() { + participantAdapter = ParticipantAdapter(participantTracks, room) + binding.participants.layoutManager = LinearLayoutManager(this) + binding.participants.adapter = participantAdapter + } + + private fun connectToRoom() { + // Get the room name and participant name from the intent + val participantName = intent.getStringExtra("participantName") ?: "Participant1" + val roomName = intent.getStringExtra("roomName") ?: "Test Room" + + binding.roomName.text = roomName + + lifecycleScope.launch { + // Specify the actions when events take place in the room + launch { + room.events.collect { event -> + when (event) { + // On every new Track received... + is RoomEvent.TrackSubscribed -> onTrackSubscribed(event) + // On every new Track destroyed... + is RoomEvent.TrackUnsubscribed -> onTrackUnsubscribed(event) + else -> {} + } + } + } + + try { + // Get token from your application server with the room name and participant name + val token = getToken(roomName, participantName) + + // Connect to the room with the LiveKit URL and the token + room.connect(LIVEKIT_URL, token) + + // Publish your camera and microphone + val localParticipant = room.localParticipant + localParticipant.setMicrophoneEnabled(true) + localParticipant.setCameraEnabled(true) + + // Add local video track to the participantTracks list + launch { + localParticipant::videoTrackPublications.flow + .collect { publications -> + val videoTrack = publications.firstOrNull()?.second as? VideoTrack + + if (videoTrack != null) { + participantTracks.add( + 0, + TrackInfo(videoTrack, participantName, true) + ) + participantAdapter.notifyItemInserted(0) + } + } + } + + binding.loader.visibility = View.GONE + } catch (e: Exception) { + println("There was an error connecting to the room: ${e.message}") + Toast.makeText(this@RoomLayoutActivity, "Failed to join room", Toast.LENGTH_SHORT) + .show() + leaveRoom() + } + } + } + + private fun onTrackSubscribed(event: RoomEvent.TrackSubscribed) { + val track = event.track + + // If the track is a video track, add it to the participantTracks list + if (track is VideoTrack) { + participantTracks.add(TrackInfo(track, event.participant.identity!!.value)) + participantAdapter.notifyItemInserted(participantTracks.size - 1) + } + } + + private fun onTrackUnsubscribed(event: RoomEvent.TrackUnsubscribed) { + val track = event.track + + // If the track is a video track, remove it from the participantTracks list + if (track is VideoTrack) { + val index = participantTracks.indexOfFirst { it.track.sid == track.sid } + + if (index != -1) { + participantTracks.removeAt(index) + participantAdapter.notifyItemRemoved(index) + } + } + } + + private fun leaveRoom() { + // Leave the room by calling 'disconnect' method over the Room object + room.disconnect() + + client.close() + + // Go back to the previous activity. + finish() + } + + override fun onDestroy() { + super.onDestroy() + leaveRoom() + } + + private fun requestNeededPermissions(onHasPermissions: () -> Unit) { + val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + var hasDenied = false + + // Check if any permissions weren't granted + for (grant in grants.entries) { + if (!grant.value) { + Toast.makeText(this, "Missing permission: ${grant.key}", Toast.LENGTH_SHORT) + .show() + + hasDenied = true + } + } + + if (!hasDenied) { + onHasPermissions() + } + } + + // Assemble the needed permissions to request + val neededPermissions = + listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA).filter { + ContextCompat.checkSelfPermission( + this, it + ) == PackageManager.PERMISSION_DENIED + }.toTypedArray() + + if (neededPermissions.isNotEmpty()) { + requestPermissionLauncher.launch(neededPermissions) + } else { + onHasPermissions() + } + } + + /** + * -------------------------------------------- + * GETTING A TOKEN FROM YOUR APPLICATION SERVER + * -------------------------------------------- + * The method below request the creation of a token to + * your application server. This prevents the need to expose + * your LiveKit API key and secret to the client side. + * + * In this sample code, there is no user control at all. Anybody could + * access your application server endpoints. In a real production + * environment, your application server must identify the user to allow + * access to the endpoints. + */ + private suspend fun getToken(roomName: String, participantName: String): String { + val response = client.post(APPLICATION_SERVER_URL + "token") { + contentType(ContentType.Application.Json) + setBody(TokenRequest(participantName, roomName)) + } + return response.body().token + } +} + +@Serializable +data class TokenRequest(val participantName: String, val roomName: String) + +@Serializable +data class TokenResponse(val token: String) diff --git a/application-client/openvidu-android/app/src/main/res/drawable/ic_launcher_background.xml b/application-client/openvidu-android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/application-client/openvidu-android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/application-client/openvidu-android/app/src/main/res/drawable/ic_launcher_foreground.xml b/application-client/openvidu-android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/res/drawable/ic_settings.xml b/application-client/openvidu-android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..6abc6b78 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/application-client/openvidu-android/app/src/main/res/drawable/rounded_corner_background.xml b/application-client/openvidu-android/app/src/main/res/drawable/rounded_corner_background.xml new file mode 100644 index 00000000..64f73c09 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/res/drawable/rounded_corner_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/res/drawable/rounded_corner_text.xml b/application-client/openvidu-android/app/src/main/res/drawable/rounded_corner_text.xml new file mode 100644 index 00000000..3ec1e3cf --- /dev/null +++ b/application-client/openvidu-android/app/src/main/res/drawable/rounded_corner_text.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/application-client/openvidu-android/app/src/main/res/layout/activity_main.xml b/application-client/openvidu-android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..e65dc590 --- /dev/null +++ b/application-client/openvidu-android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + +