diff --git a/README.md b/README.md index 265608b..b843c19 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ This demo support Android device with **Android 7.0** or later ## Usage -For example see [README.md](https://github.com/webex/webex-android-sdk/README.md) +For example see [README](https://github.com/webex/webex-android-sdk/blob/master/README.md) ## Note diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..1500b40 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,86 @@ +import com.ciscowebex.androidsdk.build.* + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.gms.google-services' // Google Services plugin +apply plugin: 'com.google.firebase.crashlytics' // Crashlytics Gradle plugin + +android { + compileSdkVersion Versions.compileSdk + buildToolsVersion Versions.buildTools + ndkVersion project.hasProperty("ndkVersion") ? project.property('ndkVersion') : Versions.ndkVersion + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + defaultConfig { + applicationId "com.cisco.sdk_android" + minSdkVersion Versions.minSdk + targetSdkVersion Versions.targetSdk + versionCode 30001 + versionName "3.0.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + buildConfigField "String", "CLIENT_ID", "${CLIENT_ID}" + buildConfigField "String", "CLIENT_SECRET", "${CLIENT_SECRET}" + buildConfigField "String", "REDIRECT_URI", "${REDIRECT_URI}" + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + buildFeatures { + dataBinding true + } +} + +dependencies { + implementation 'com.ciscowebex:androidsdk:3.0.0@aar' + + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation Dependencies.kotlinStdLib + implementation Dependencies.coreKtx + implementation Dependencies.appCompat + implementation Dependencies.constraintLayout + implementation Dependencies.material + implementation Dependencies.recyclerview + implementation Dependencies.cardview + implementation Dependencies.viewpager2 + implementation Dependencies.koin + implementation Dependencies.koinViewModel + implementation Dependencies.swiperefresh + implementation Dependencies.media + implementation Dependencies.nimbusJosh + + // RXJAVA + implementation Dependencies.rxjava + implementation Dependencies.rxandroid + implementation Dependencies.rxkotlin + + testImplementation Dependencies.Test.junit + androidTestImplementation Dependencies.Test.androidxJunit + androidTestImplementation Dependencies.Test.espressoCore + androidTestImplementation Dependencies.Test.espressoContrib + androidTestImplementation Dependencies.Test.espressoWeb + androidTestImplementation Dependencies.Test.espressoIntents + androidTestImplementation Dependencies.Test.rules + androidTestImplementation Dependencies.Test.testExt + debugImplementation (Dependencies.Test.fragmentScenerio) { + exclude group: 'androidx.test', module: 'monitor' + } + implementation platform(Dependencies.firebaseBom) + implementation Dependencies.firebaseMessaging + implementation Dependencies.firebaseAnalytics + implementation Dependencies.firebaseCrashlytics + implementation Dependencies.gson + +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/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/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt new file mode 100644 index 0000000..ba4565a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/HomeActivityTest.kt @@ -0,0 +1,105 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.content.Intent +import android.net.Uri +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.* +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.rule.GrantPermissionRule +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.search.SearchActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.hamcrest.Matchers.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.TimeUnit + + +class HomeActivityTest : KitchenSinkTest() { + + @Rule @JvmField + val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + android.Manifest.permission.CAMERA, + android.Manifest.permission.RECORD_AUDIO, + android.Manifest.permission.READ_PHONE_STATE + ) + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testInitiateCallButton_homeActivity() { + clickOnView(R.id.iv_startCall) + intended(hasComponent(SearchActivity::class.java.name)) + } + + @Test + fun testWaitingCallButton_homeActivity() { + clickOnView(R.id.iv_waitingCall) + intended(hasComponent(CallActivity::class.java.name)) + } + + @Test + fun testFeedbackButton_homeActivity() { + clickOnView(R.id.iv_feedback) + val subject = targetContext.getString(R.string.feedbackLogsSubject) + + val expectedIntent = allOf( + hasAction(Intent.ACTION_CHOOSER), + hasExtra( + equalTo(Intent.EXTRA_INTENT), + allOf( + hasAction(Intent.ACTION_SENDTO), + hasData(Uri.parse("mailto:")), + hasExtra( + `is`(Intent.EXTRA_SUBJECT), + `is`(subject) + ) + ) + ) + ) + + intended(expectedIntent) + + } + + @Test + fun testLogoutButton_homeActivity() { + clickOnView(R.id.iv_logout) + assertViewDisplayed(R.id.progressLayout) + WaitUtils.waitForCondition( + 60, TimeUnit.SECONDS, + { + val rootLoginActivity = getActivity()?.findViewById(R.id.rootLoginActivity) + rootLoginActivity != null + }, + { + "$TAG:: Not able to find rootLoginActivity" + } + ) + } + + @Test + fun testMessageButton_homeActivity() { + clickOnView(R.id.iv_messaging) + intended(hasComponent(MessagingActivity::class.java.name)) + } + + @Test + fun testGetMeButton_homeActivity() { + clickOnView(R.id.iv_getMe) + onView(withId(R.id.dialogOk)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt new file mode 100644 index 0000000..2f25e77 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt @@ -0,0 +1,288 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.app.Activity +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.annotation.IdRes +import androidx.annotation.NonNull +import androidx.annotation.StringRes +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.PerformException +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity +import com.ciscowebex.androidsdk.kitchensink.utils.RecyclerViewMatchers.withRecyclerView +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils.waitForCondition +import com.google.android.material.tabs.TabLayout +import org.hamcrest.Matcher +import org.hamcrest.Matchers.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.rules.Timeout +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +abstract class KitchenSinkTest { + + val TAG = KitchenSinkTest::class.java.simpleName + val TIME_1_SEC: Long = 1000 + + val targetContext: Context by lazy { + InstrumentationRegistry.getInstrumentation().targetContext as Context + } + + @get: Rule + val activityRule = ActivityScenarioRule(LoginActivity::class.java) + + @Rule + @JvmField + var teamsTestTimeout: Timeout = Timeout(5, TimeUnit.MINUTES) + + @Before + open fun initTests() { + Intents.init() + } + + @After + fun releaseIntents(){ + Intents.release() + } + + fun setUpLogin() { + val testEmail = "xeiotulvlhijlxtrmw@awdrt.com" + val testPassword = "Test1234" + + activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + + assertViewDisplayed(R.id.btn_oauth_login) + clickOnView(R.id.btn_oauth_login) + + WaitUtils.sleep(2000) + + if (getActivity() is LoginActivity) { + assertViewDisplayed(R.id.loginWebview) + + Web.onWebView().forceJavascriptEnabled() + + WaitUtils.waitElementToAppear("IDToken1") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "IDToken1")) + + // Clear previous input + .perform(DriverAtoms.clearElement()) + // Enter text into the input element + .perform(DriverAtoms.webKeys(testEmail)) + WaitUtils.waitElementToAppear("IDButton2") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "IDButton2")).perform(DriverAtoms.webClick()) + WaitUtils.waitElementToAppear("IDToken2") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "IDToken2")) + .perform(DriverAtoms.clearElement()) + .perform(DriverAtoms.webKeys(testPassword)) + + WaitUtils.waitElementToAppear("Button1") + Web.onWebView(withId(R.id.loginWebview)).withElement(DriverAtoms.findElement(Locator.ID, "Button1")).perform(DriverAtoms.webClick()) + } + + waitForCondition( + 60, TimeUnit.SECONDS, + { + val homeActivityRootView = getActivity()?.findViewById(R.id.rootHomeActivity) + homeActivityRootView != null + }, + { + "$TAG:: Not able to find homeActivityRootView" + } + ) + + activityRule.scenario.close() + } + + protected fun typeTextAction(@IdRes viewId: Int, textToType: String = "Summer is good") { + onView(withId(viewId)) + .check(matches(isDisplayed())) + .perform(typeText(textToType)) + closeSoftKeyboard() + } + + protected fun assertView(@StringRes textOnView: Int) { + onView(withText(textOnView)).check(matches(isDisplayed())) + } + + protected fun assertView(@IdRes viewId: Int, @StringRes textOnView: Int) { + onView(allOf(withId(viewId), withText(textOnView))).check(matches(isDisplayed())) + } + + protected fun assertViewDisplayed(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(isDisplayed())) + } + + protected fun assertViewNotDisplayed(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(not(isDisplayed()))) + } + + protected fun assertViewWithContentDescription(@IdRes viewId: Int, @StringRes stringId: Int) { + onView(allOf(withId(viewId), withContentDescription(stringId))) + } + + protected fun clickOnView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(click()) + } + + protected fun clickOnViewWithText(@StringRes stringId: Int) { + onView(withText(stringId)).perform(click()) + } + + protected fun longClickOnView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(longClick()) + } + + protected fun assertViewExists(@IdRes viewId: Int) { + onView(allOf(withId(viewId))).check(matches(isDisplayed())) + } + + protected fun assertViewWithText(@IdRes viewId: Int, @NonNull textOnView: String) { + onView(allOf(withId(viewId), withText(containsString(textOnView)))).check(matches(isDisplayed())) + } + + protected fun assertViewWithText(@IdRes viewId: Int, @NonNull @StringRes stringId: Int) { + onView(allOf(withId(viewId), withText(stringId))).check(matches(isDisplayed())) + } + + protected fun getResourceName(@IdRes id: Int): String { + return targetContext.resources.getResourceName(id) + } + + protected fun selectTab(tabIndex: Int): ViewAction { + return object : ViewAction { + override fun getDescription() = "with tab at index $tabIndex" + + override fun getConstraints() = allOf(isDisplayed(), isAssignableFrom(TabLayout::class.java)) + + override fun perform(uiController: UiController, view: View) { + val tabLayout = view as TabLayout + val tabAtIndex: TabLayout.Tab = tabLayout.getTabAt(tabIndex) + ?: throw PerformException.Builder() + .withCause(Throwable("No tab at index $tabIndex")) + .build() + + tabAtIndex.select() + } + } + } + + fun getActivity(): Activity? { + val activity = arrayOfNulls(1) + onView(isRoot()).check { view, _ -> + var checkedView = view + while (checkedView is ViewGroup && checkedView.childCount > 0) { + checkedView = checkedView.getChildAt(0) + if (checkedView.context is Activity) { + activity[0] = checkedView.context as Activity + break + } + } + } + return activity[0] + } + + protected fun longClickOnListItem(@IdRes viewId: Int, itemPosition: Int, @IdRes targetViewId: Int? = null) { + if (targetViewId == null) { + onView(withRecyclerView(viewId).atPosition(itemPosition)) + } else { + onView(withRecyclerView(viewId).atPositionOnView(itemPosition, targetViewId)) + }.check(matches(isDisplayed())).perform(longClick()) + } + + protected fun clickChildViewWithId(id: Int): ViewAction? { + return object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayed() + } + + override fun getDescription(): String { + return "Click on a child view with specified id." + } + + override fun perform(uiController: UiController, view: View) { + val v = view.findViewById(id) + v.performClick() + } + } + } + + protected fun longclickChildViewWithId(id: Int): ViewAction? { + return object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayed() + } + + override fun getDescription(): String { + return "Click on a child view with specified id." + } + + override fun perform(uiController: UiController, view: View) { + val v = view.findViewById(id) + v.performLongClick() + } + } + } + + protected fun withCustomConstraints(action: ViewAction, constraints: Matcher): ViewAction? { + return object : ViewAction { + override fun getConstraints(): Matcher { + return constraints + } + + override fun getDescription(): String { + return action.description + } + + override fun perform(uiController: UiController, view: View) { + action.perform(uiController, view) + } + } + } + + protected fun ScrollToBottomAction(): ViewAction? { + return object : ViewAction { + override fun getDescription(): String { + return "scroll RecyclerView to bottom" + } + + override fun getConstraints(): Matcher { + return allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed()) + } + + override fun perform(uiController: UiController?, view: View?) { + val recyclerView = view as RecyclerView + val itemCount = recyclerView.adapter?.itemCount + val position = itemCount?.minus(1) ?: 0 + recyclerView.scrollToPosition(position) + uiController?.loopMainThreadUntilIdle() + } + } + } + + protected fun clickOnItemInRecyclerView(@IdRes viewId: Int, itemPosition: Int, @IdRes targetViewId: Int? = null) { + if (targetViewId == null) { + onView(withRecyclerView(viewId).atPosition(itemPosition)) + } else { + onView(withRecyclerView(viewId).atPositionOnView(itemPosition, targetViewId)) + }.check(matches(isDisplayed())).perform(click()) + } +} diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt new file mode 100644 index 0000000..b2d8d7e --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/SearchActivityTest.kt @@ -0,0 +1,43 @@ +package com.ciscowebex.androidsdk.kitchensink + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers +import com.ciscowebex.androidsdk.kitchensink.search.SearchCommonFragment +import org.junit.Before +import org.junit.Test + + +class SearchActivityTest : KitchenSinkTest() { + + companion object{ + const val CALL_HISTORY_TAB_INDEX = 2 + const val SPACES_TAB_INDEX = 3 + } + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testCallHistory_searchActivity() { + intended(IntentMatchers.hasComponent(HomeActivity::class.java.name)) + clickOnView(R.id.iv_startCall) + selectTab(CALL_HISTORY_TAB_INDEX) + launchFragmentInContainer() + assertViewDisplayed(R.id.recycler_view) + } + + @Test + fun testSpaces_searchActivity(){ + intended(IntentMatchers.hasComponent(HomeActivity::class.java.name)) + clickOnView(R.id.iv_startCall) + selectTab(SPACES_TAB_INDEX) + launchFragmentInContainer() + assertViewDisplayed(R.id.recycler_view) + } + + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt new file mode 100644 index 0000000..ab98a0f --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivityTest.kt @@ -0,0 +1,18 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import androidx.test.filters.LargeTest +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4ClassRunner::class) +@LargeTest +open class LoginActivityTest : KitchenSinkTest() { + + @Test + fun testWebExLogin_LoginActivity() { + setUpLogin() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt new file mode 100644 index 0000000..fb7f40f --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragmentTest.kt @@ -0,0 +1,85 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.hamcrest.CoreMatchers.allOf +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DialFragmentTest : KitchenSinkTest() { + + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testDialKeys() { + launchFragmentInContainer() + // Dial "0123456789" + clickOnView(R.id.tv_number_0) + clickOnView(R.id.tv_number_1) + clickOnView(R.id.tv_number_2) + clickOnView(R.id.tv_number_3) + clickOnView(R.id.tv_number_4) + clickOnView(R.id.tv_number_5) + clickOnView(R.id.tv_number_6) + clickOnView(R.id.tv_number_7) + clickOnView(R.id.tv_number_8) + clickOnView(R.id.tv_number_9) + longClickOnView(R.id.tv_number_0) + clickOnView(R.id.tv_number_star) + clickOnView(R.id.tv_number_hash) + // Check the dialed value + assertViewWithText(R.id.et_dial_input, "0123456789+*#") + } + + @Test + fun testBackPress() { + launchFragmentInContainer() + for (i in 1..5) { + clickOnView(R.id.tv_number_0) + } + assertViewWithText(R.id.et_dial_input, "00000") + clickOnView(R.id.ib_backspace) + assertViewWithText(R.id.et_dial_input, "0000") + longClickOnView(R.id.ib_backspace) + assertViewWithText(R.id.et_dial_input, "") + } + + @Test + fun testToggleKeypadAndDialpad() { + launchFragmentInContainer() + assertViewDisplayed(R.id.ib_keypad_toggle) + clickOnView(R.id.ib_keypad_toggle) + + assertViewNotDisplayed(R.id.ib_keypad_toggle) + assertViewDisplayed(R.id.ib_numpad_toggle) + assertViewNotDisplayed(R.id.dial_buttons_container) + + clickOnView(R.id.ib_numpad_toggle) + + assertViewNotDisplayed(R.id.ib_numpad_toggle) + assertViewDisplayed(R.id.ib_keypad_toggle) + assertViewDisplayed(R.id.dial_buttons_container) + } + + @Test + fun testCallButton() { + launchFragmentInContainer() + for (i in 1..5) { + clickOnView(R.id.tv_number_1) + } + clickOnView(R.id.ib_startCall) + intended(allOf(hasComponent(CallActivity::class.java.name), + hasExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID, "11111"))) + + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt new file mode 100644 index 0000000..46d388a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/PostMessageTest.kt @@ -0,0 +1,168 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + + +import android.view.View +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters.SpacesClientViewHolder +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.SpaceDetailActivity +import com.ciscowebex.androidsdk.kitchensink.person.PeopleClientViewHolder +import com.ciscowebex.androidsdk.kitchensink.person.PeopleFragment +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException +import java.util.Random + + +@RunWith(AndroidJUnit4ClassRunner::class) +class PostMessageTest : KitchenSinkTest() { + var min = 0 + var max = 100 + + var random = Random() + private var testMessage = "Hello Test Message " + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + val number = random.nextInt(max - min + 1) + min + testMessage += number + } + + @Test + fun postMessageBySpaceId() { + launchFragmentInContainer(null, R.style.AppTheme) + assertViewDisplayed(R.id.spacesRecyclerView) + onView(withId(R.id.spacesRecyclerView)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.spacesRecyclerView))).perform(RecyclerViewActions.actionOnItemAtPosition(0, clickChildViewWithId(R.id.spaceTitleTextView))) + Intents.intended(IntentMatchers.hasComponent(SpaceDetailActivity::class.java.name)) + onView(withId(R.id.postMessageFAB)).check(matches(isDisplayed())).perform(clickChildViewWithId(R.id.postMessageFAB)) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } + + private fun peopleFragmentBottomSheet() { + launchFragmentInContainer(null, R.style.AppTheme) + WaitUtils.sleep(2000) + assertViewDisplayed(R.id.recycler_view) + assertViewDisplayed(R.id.search_view) + onView(withId(R.id.recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.personClientLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.peopleOptionsBottomSheet) + } + + @Test + fun fetchPersonDetailByID() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.fetchPersonByID) + clickOnView(R.id.fetchPersonByID) + WaitUtils.sleep(2000) + assertViewDisplayed(R.id.rootPersonDetailDialog) + } + + @Test + fun postMessageByPersonID() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.postMessageByID) + clickOnView(R.id.postMessageByID) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } + + @Test + fun postMessageByPersonEmail() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.postMessageByEmail) + clickOnView(R.id.postMessageByEmail) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } + + @Throws(IOException::class) + private fun getFileFromAssets(fileName: String): File = File(targetContext.cacheDir, fileName) + .also { + if (!it.exists()) { + it.outputStream().use { cache -> + targetContext.resources.assets.open(fileName).use { inputStream -> + inputStream.copyTo(cache) + } + } + } + } + + private fun addItemToRecyclerView(file: File): Matcher { + return object : BoundedMatcher(RecyclerView::class.java) { + override fun describeTo(description: Description?) { + } + + override fun matchesSafely(item: RecyclerView?): Boolean { + item?.adapter?.let { + val adapter = it as MessageComposerActivity.UploadAttachmentsAdapter + adapter.attachedFiles.add(file) + adapter.notifyDataSetChanged() + return true + } + + return false + } + } + } + + @Test + fun sendContentByPersonEmail() { + peopleFragmentBottomSheet() + assertViewDisplayed(R.id.postMessageByEmail) + clickOnView(R.id.postMessageByEmail) + WaitUtils.sleep(1000) + Intents.intended(IntentMatchers.hasComponent(MessageComposerActivity::class.java.name)) + onView(withId(R.id.message)).check(matches(isDisplayed())).perform(typeText(testMessage), closeSoftKeyboard()) + val filePath = getFileFromAssets("cisco.png").absolutePath + val file = File(filePath) + val exist = File(filePath).exists() + onView(withId(R.id.attachment_recycler_view)).check(matches(addItemToRecyclerView(file))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.sendButton) + clickOnView(R.id.sendButton) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootPostMessageDetailDialog) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt new file mode 100644 index 0000000..d5916e1 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/members/MembershipFragmentTest.kt @@ -0,0 +1,38 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.members + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class MembershipFragmentTest: KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun membershipTest_membershipFragmentTest(){ + clickOnView(R.id.iv_messaging) + Intents.intended(IntentMatchers.hasComponent(MessagingActivity::class.java.name)) + + onView(withId(R.id.view_pager)).perform(swipeLeft()) + onView(withId(R.id.view_pager)).perform(swipeLeft()) + onView(withId(R.id.view_pager)).perform(swipeLeft()) + + assertViewDisplayed(R.id.membershipsRecyclerView) + onView(withId(R.id.membershipsRecyclerView)).check(matches(hasDescendant(withId(R.id.membershipPersonDisplayNameTextView)))) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt new file mode 100644 index 0000000..746da7c --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragmentTest.kt @@ -0,0 +1,41 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class SearchPeopleFragmentTest : KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testSearchPeopleByName() { + launchFragmentInContainer() + assertViewDisplayed(R.id.search_view) + typeTextAction(R.id.search_view, "rohit sharma") + onView(withId(R.id.recycler_view)) + .check(matches(hasDescendant(withText("Rohit Sharma")))) + } + + + @Test + fun testSearchPeopleByEmailId() { + launchFragmentInContainer() + assertViewDisplayed(R.id.search_view) + typeTextAction(R.id.search_view, "webextestac@gmail.com") + onView(withId(R.id.recycler_view)) + .check(matches(hasDescendant(withText("Rohit Sharma")))) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt new file mode 100644 index 0000000..c2589c6 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SpaceMessageDetailsFragmentTest.kt @@ -0,0 +1,47 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.* +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test + + +class SpaceMessageDetailsFragmentTest : KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testTeamMembershipList_spaceMessageDetailsFragment() { + gotoSpaces() + + clickOnItemInRecyclerView(R.id.spacesRecyclerView, 1) + + WaitUtils.sleep(1000) + onView(withId(R.id.spaceMessageRecyclerView)) + .perform(swipeDown()) + WaitUtils.sleep(1000) + clickOnItemInRecyclerView(R.id.spaceMessageRecyclerView, 0) + + assertViewDisplayed(R.id.msgIdTextView) + } + + private fun gotoSpaces(){ + clickOnView(R.id.iv_messaging) + intended(hasComponent(MessagingActivity::class.java.name)) + + onView(withId(R.id.view_pager)) + .perform(swipeLeft()) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt new file mode 100644 index 0000000..d0ee44d --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragmentTest.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test + +class SpacesFragmentTest : KitchenSinkTest() { + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testDeleteSpace() { + launchFragmentInContainer(themeResId = R.style.Theme_AppCompat) + longClickOnListItem(R.id.spacesRecyclerView, 0) + WaitUtils.sleep(TIME_1_SEC) + onView(withId(R.id.deleteSpace)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + clickOnView(R.id.deleteSpace) + WaitUtils.sleep(TIME_1_SEC) + onView(withText(R.string.delete_space)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + + @Test + fun testShowSpaceMembersWithReadStatus() { + launchFragmentInContainer(themeResId = R.style.Theme_AppCompat) + longClickOnListItem(R.id.spacesRecyclerView, 0) + WaitUtils.sleep(TIME_1_SEC) + onView(withId(R.id.showSpaceMembersWithReadStatus)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + clickOnView(R.id.showSpaceMembersWithReadStatus) + intended(hasComponent(MembershipReadStatusActivity::class.java.name)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt new file mode 100644 index 0000000..f8ce36a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamFragmentTest.kt @@ -0,0 +1,95 @@ +package com.ciscowebex.androidsdk.kitchensink.teams.membership + +import androidx.test.espresso.Espresso.closeSoftKeyboard +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withHint +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.search.MessagingSearchActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsClientViewHolder +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipActivity +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random + + +@RunWith(AndroidJUnit4ClassRunner::class) +class TeamFragmentTest : KitchenSinkTest() { + + val testTeamName = "Test Team" + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testTeamMembershipList_teamFragment() { + goToMessagingActivity() + longClickOnListItem(R.id.teamsRecyclerView, 0) + WaitUtils.sleep(TIME_1_SEC) + assertViewDisplayed(R.id.teamOptionsLabel) + clickOnView(R.id.getMembers) + + intended(hasComponent(TeamMembershipActivity::class.java.name)) + assertViewDisplayed(R.id.membershipsRecyclerView) + } + + private fun goToMessagingActivity() { + clickOnView(R.id.iv_messaging) + intended(hasComponent(MessagingActivity::class.java.name)) + } + + @Test + fun addTeamMember_teamFragment() { + goToMessagingActivity() + clickOnView(R.id.addTeamsFAB) + onView(withText(R.string.add_team)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + val testTeam = testTeamName + Random.nextInt(0, 1000) + onView(withHint(R.string.team_name_hint)).check(matches(isDisplayed())) + .perform(ViewActions.typeText(testTeam)) + closeSoftKeyboard() + clickOnViewWithText(android.R.string.ok) + + onView(withId(R.id.teamsRecyclerView)) + .perform(ViewActions.swipeDown()) + WaitUtils.sleep(TIME_1_SEC) + onView(withId(R.id.teamsRecyclerView)).check(matches(hasDescendant(withText(testTeam)))) + } + + @Test + fun addPersonToTeam_teamFragment(){ + goToMessagingActivity() + onView(withId(R.id.teamsRecyclerView)).perform(RecyclerViewActions.actionOnItemAtPosition(0, clickChildViewWithId(R.id.iv_add_to_team))) + WaitUtils.sleep(TIME_1_SEC) + intended(hasComponent(MessagingSearchActivity::class.java.name)) + } + + @Test + fun testBottomSheetOptions_teamsFragment(){ + goToMessagingActivity() + longClickOnListItem(R.id.teamsRecyclerView, 0) + assertViewDisplayed(R.id.getMembers) + assertViewDisplayed(R.id.editTeamName) + assertViewDisplayed(R.id.addSpaceFromTeam) + assertViewDisplayed(R.id.deleteTeam) + assertViewDisplayed(R.id.cancel) + } +} diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt new file mode 100644 index 0000000..91136bb --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/teams/membership/TeamMembershipFragmentTest.kt @@ -0,0 +1,88 @@ +package com.ciscowebex.androidsdk.kitchensink.teams.membership + +import android.os.Bundle +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipFragment +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.junit.Before +import org.junit.Test + + +class TeamMembershipFragmentTest : KitchenSinkTest() { + var teamId = "1d3a4ec0-15b0-11eb-b0f2-7bf0c59106a7" + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + } + + @Test + fun testTeamMembershipList_homeActivity() { + clickOnView(R.id.iv_messaging) + + intended(hasComponent(MessagingActivity::class.java.name)) + longClickOnListItem(R.id.teamsRecyclerView, 0) + WaitUtils.sleep(1000) + + assertViewDisplayed(R.id.teamOptionsLabel) + clickOnView(R.id.getMembers) + + intended(hasComponent(TeamMembershipActivity::class.java.name)) + assertViewDisplayed(R.id.membershipsRecyclerView) + } + + @Test + fun testTeamMembershipDetails() { + clickOnView(R.id.iv_messaging) + + intended(hasComponent(MessagingActivity::class.java.name)) + longClickOnListItem(R.id.teamsRecyclerView, 0) + WaitUtils.sleep(1000) + + assertViewDisplayed(R.id.teamOptionsLabel) + clickOnView(R.id.getMembers) + + intended(hasComponent(TeamMembershipActivity::class.java.name)) + assertViewDisplayed(R.id.membershipsRecyclerView) + + longClickOnListItem(R.id.membershipsRecyclerView, 0) + WaitUtils.sleep(1000) + + + assertViewDisplayed(R.id.teamMemberActionOptionsLabel) + clickOnView(R.id.getMembershipDetails) + + assertViewDisplayed(R.id.rootMemberDetailsDialog) + + } + + @Test + fun testDeleteTeamMembership() { + val bundle = Bundle().apply { + putString(Constants.Bundle.TEAM_ID, teamId) + } + launchFragmentInContainer(bundle, R.style.Theme_AppCompat) + WaitUtils.sleep(1000) + longClickOnListItem(R.id.membershipsRecyclerView, 0) + assertViewDisplayed(R.id.deleteMembership) + WaitUtils.sleep(1000) + clickOnView(R.id.deleteMembership) + onView(withText(R.string.confirm_delete_membership_action)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt new file mode 100644 index 0000000..849c832 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/RecyclerViewMatchers.kt @@ -0,0 +1,87 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.res.Resources +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.ViewAssertion +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher +import org.hamcrest.core.Is.`is` +import org.junit.Assert.assertThat + +object RecyclerViewMatchers { + + fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher { + return RecyclerViewMatcher(recyclerViewId) + } + + fun havingItemCount(itemCount: Int): RecyclerViewItemCountAssertion { + return RecyclerViewItemCountAssertion(itemCount) + } + + class RecyclerViewMatcher(private val recyclerViewId: Int) { + + fun atPosition(position: Int): Matcher { + return atPositionOnView(position, View.NO_ID) + } + + fun atPositionOnView(position: Int, targetViewId: Int): Matcher { + + return object : TypeSafeMatcher() { + var resources: Resources? = null + var childView: View? = null + + override fun describeTo(description: Description) { + var idDescription = Integer.toString(recyclerViewId) + if (this.resources != null) { + idDescription = try { + this.resources!!.getResourceName(recyclerViewId) + } catch (var4: Resources.NotFoundException) { + String.format("%s (resource name not found)", recyclerViewId) + } + } + description.appendText("RecyclerView with id: $idDescription at position: $position on child view with id $targetViewId") + } + + public override fun matchesSafely(view: View): Boolean { + + this.resources = view.resources + + if (childView == null) { + val recyclerView: RecyclerView? = view.rootView.findViewById(recyclerViewId) as RecyclerView + if (recyclerView != null && recyclerView.id == recyclerViewId) { + val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) + if (viewHolder != null) { + childView = viewHolder.itemView + } + } else { + return false + } + } + + return if (targetViewId == View.NO_ID) { + view === childView + } else { + val targetView = childView?.findViewById(targetViewId) + view === targetView + } + } + } + } + } + + class RecyclerViewItemCountAssertion(private val expectedCount: Int) : ViewAssertion { + + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + if (noViewFoundException != null) { + throw noViewFoundException + } + + val recyclerView = view as RecyclerView + val adapter = recyclerView.adapter + assertThat(adapter!!.itemCount, `is`(expectedCount)) + } + } +} diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt new file mode 100644 index 0000000..c7db3e6 --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/utils/WaitUtils.kt @@ -0,0 +1,71 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.os.SystemClock +import android.util.Log +import androidx.test.espresso.web.model.Atom +import androidx.test.espresso.web.model.ElementReference +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import junit.framework.Assert.fail +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +object WaitUtils { + private const val TAG = "WaitUtils" + internal fun sleep(millis: Long) { + try { + Thread.sleep(millis) + } catch (ignored: InterruptedException) { + } + } + + fun waitElementToAppear(id: String) { + var maxTries = 0 + while (maxTries != 60) { + Thread.sleep(1000) + try { + if(DriverAtoms.findElement(Locator.ID, id) != null){ + break + } + }catch (e: TimeoutException){ } + + maxTries++ + } + } + + fun waitForCondition(time: Long, unit: TimeUnit, condition: () -> Boolean, failureMessage: () -> String, warnAfter: Long = 0): Long { + return waitForCondition(unit.toMillis(time), condition, failureMessage, warnAfter) + } + + fun waitForCondition(timeout: Long, condition: () -> Boolean, failureMessage: () -> String, warnAfter: Long = 0): Long { + val startTime = SystemClock.uptimeMillis() + val endTime = startTime + timeout + + while (true) { + val timedOut = SystemClock.uptimeMillis() > endTime + + if (timedOut) { + val format = "Condition not satisified after $timeout ms" + + Log.i(TAG, "waitForCondition timedOut, $format - ${failureMessage()}") + + fail(failureMessage()) + } + + sleep(50) + + if (condition()) { + val duration = SystemClock.uptimeMillis() - startTime + + if ((warnAfter > 0) && (duration > warnAfter)) { + Log.i(TAG, "Condition took $duration ms, longer than $warnAfter ms") + } else { + Log.i(TAG, "Condition took $duration ms") + } + + return duration + } + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt new file mode 100644 index 0000000..626208a --- /dev/null +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookTest.kt @@ -0,0 +1,118 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast +import androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkTest +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.utils.WaitUtils +import org.hamcrest.CoreMatchers.allOf +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Random + + +@RunWith(AndroidJUnit4ClassRunner::class) +class WebhookTest : KitchenSinkTest() { + var min = 0 + var max = 100 + + var random = Random() + private var webhookName = "TestingWebhook " + private var webhookUpdateName = "TestingUpdateWebhook " + private var webhookURL = "https://webhook.site/6d71f999-00a9-43d9-ac9d-7ea29d1538f9" + private var resource = "memberships" + private var event = "created" + private var secret = "secret100" + + @Before + override fun initTests() { + super.initTests() + setUpLogin() + val number = random.nextInt(max - min + 1) + min + webhookName += number + webhookUpdateName += number + clickOnView(R.id.iv_webhook) + Intents.intended(IntentMatchers.hasComponent(WebhooksActivity::class.java.name)) + WaitUtils.sleep(2000) + } + + @Test + fun webhook0_Create() { + assertViewDisplayed(R.id.addWebhookButton) + clickOnView(R.id.addWebhookButton) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.rootWebhookCreateDialog) + onView(withId(R.id.nameEditText)).check(matches(isDisplayed())).perform(typeText(webhookName), closeSoftKeyboard()) + onView(withId(R.id.targetUrlEditText)).check(matches(isDisplayed())).perform(typeText(webhookURL), closeSoftKeyboard()) + onView(withId(R.id.resourceEditText)).check(matches(isDisplayed())).perform(typeText(resource), closeSoftKeyboard()) + onView(withId(R.id.eventEditText)).check(matches(isDisplayed())).perform(typeText(event), closeSoftKeyboard()) + onView(withId(R.id.secretEditText)).check(matches(isDisplayed())).perform(typeText(secret), closeSoftKeyboard()) + onView(withText("CREATE")).inRoot(isDialog()) + .check(matches(isDisplayed())) + .perform(click()) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasDescendant(withText(webhookName)))) + } + + @Test + fun webhook1_Get() { + onView(withId(R.id.webhook_recycler_view)).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(85))) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.webhook_recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.rootListItemLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.webhookOptionsBottomSheet) + assertViewDisplayed(R.id.webhookGetDetails) + clickOnView(R.id.webhookGetDetails) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootWebHookDetailDialog) + } + + @Test + fun webhook2_Delete() { + onView(withId(R.id.webhook_recycler_view)).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(85))) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.webhook_recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.rootListItemLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.webhookOptionsBottomSheet) + assertViewDisplayed(R.id.webhookDelete) + clickOnView(R.id.webhookDelete) + } + + @Test + fun webhook3_Update() { + onView(withId(R.id.webhook_recycler_view)).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(85))) + WaitUtils.sleep(5000) + onView(withId(R.id.webhook_recycler_view)).check(matches(hasMinimumChildCount(1))) + onView(allOf(withId(R.id.webhook_recycler_view))).perform(RecyclerViewActions.actionOnItemAtPosition(0, longclickChildViewWithId(R.id.rootListItemLayout))) + WaitUtils.sleep(1000) + assertViewDisplayed(R.id.webhookOptionsBottomSheet) + assertViewDisplayed(R.id.webhookUpdate) + clickOnView(R.id.webhookUpdate) + assertViewDisplayed(R.id.rootWebhookUpdateDialog) + onView(withId(R.id.nameEditText)).check(matches(isDisplayed())).perform(replaceText(webhookUpdateName), closeSoftKeyboard()) + onView(withText("UPDATE")).inRoot(isDialog()) + .check(matches(isDisplayed())) + .perform(click()) + WaitUtils.sleep(5000) + assertViewDisplayed(R.id.rootWebHookDetailDialog) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1ea0c40 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/cisco.png b/app/src/main/assets/cisco.png new file mode 100644 index 0000000..f362be9 Binary files /dev/null and b/app/src/main/assets/cisco.png differ diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt new file mode 100644 index 0000000..049e793 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/AppModule.kt @@ -0,0 +1,9 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.kitchensink.utils.PermissionsHelper +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val mainAppModule = module { + single { PermissionsHelper(androidContext()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt new file mode 100644 index 0000000..7d84dc1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseActivity.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.search.SearchActivity +import com.ciscowebex.androidsdk.kitchensink.utils.PermissionsHelper +import org.koin.android.ext.android.inject +import org.koin.android.viewmodel.ext.android.viewModel + +open class BaseActivity : AppCompatActivity() { + var tag = "BaseActivity" + private val permissionsHelper: PermissionsHelper by inject() + val webexViewModel: WebexViewModel by viewModel() + + fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + builder.setTitle(R.string.error_occurred) + val message = TextView(this) + message.setPadding(10, 10, 10, 10) + message.text = errorMessage + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt new file mode 100644 index 0000000..64700f6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/BaseViewModel.kt @@ -0,0 +1,17 @@ +package com.ciscowebex.androidsdk.kitchensink + +import androidx.lifecycle.ViewModel +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable + +abstract class BaseViewModel() : ViewModel() { + private val compositeDisposable = CompositeDisposable() + + override fun onCleared() { + super.onCleared() + compositeDisposable.clear() + } + + + fun Disposable.autoDispose() = compositeDisposable.add(this) +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt new file mode 100644 index 0000000..8a4fc94 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/HomeActivity.kt @@ -0,0 +1,211 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.util.Log +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityHomeBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingActivity +import com.ciscowebex.androidsdk.kitchensink.cucm.UCLoginActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.MessageDetailsDialogFragment +import com.ciscowebex.androidsdk.kitchensink.person.PersonDialogFragment +import com.ciscowebex.androidsdk.kitchensink.person.PersonViewModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.clearLoginTypePref +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.saveLoginTypePref +import com.ciscowebex.androidsdk.kitchensink.webhooks.WebhooksActivity +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.extras.ExtrasActivity +import com.ciscowebex.androidsdk.kitchensink.search.SearchActivity +import com.ciscowebex.androidsdk.kitchensink.setup.SetupActivity +import org.koin.android.ext.android.inject + +class HomeActivity : BaseActivity() { + + lateinit var binding: ActivityHomeBinding + private val personViewModel : PersonViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "HomeActivity" + + val authenticator = webexViewModel.webex.authenticator + + webexViewModel.enableBackgroundConnection(webexViewModel.enableBgConnectiontoggle) + webexViewModel.setLogLevel(webexViewModel.logFilter) + webexViewModel.enableConsoleLogger(webexViewModel.isConsoleLoggerEnabled) + + authenticator?.let { + when (it) { + is OAuthWebViewAuthenticator -> { + saveLoginTypePref(this, LoginActivity.LoginType.OAuth) + } + else -> { + saveLoginTypePref(this, LoginActivity.LoginType.JWT) + } + } + } + + webexViewModel.signOutListenerLiveData.observe(this@HomeActivity, Observer { + it?.let { + if (it) { + clearLoginTypePref(this) + (application as KitchenSinkApp).unloadKoinModules() + finish() + } + else { + binding.progressLayout.visibility = View.GONE + } + } + }) + + + webexViewModel.cucmLiveData.observe(this@HomeActivity, Observer { + if (it != null) { + when (WebexRepository.CucmEvent.valueOf(it.first.name)) { + WebexRepository.CucmEvent.OnUCServerConnectionStateChanged -> { + updateUCData() + } + else -> {} + } + } + }) + + DataBindingUtil.setContentView(this, R.layout.activity_home) + .also { binding = it } + .apply { + + ivStartCall.setOnClickListener { + startActivity(Intent(this@HomeActivity, SearchActivity::class.java)) + } + + ivWaitingCall.setOnClickListener { + startActivity(CallActivity.getIncomingIntent(this@HomeActivity)) + } + + ivMessaging.setOnClickListener { + startActivity(Intent(this@HomeActivity, MessagingActivity::class.java)) + } + + ivUcLogin.setOnClickListener { + startActivity(Intent(this@HomeActivity, UCLoginActivity::class.java)) + } + + ivWebhook.setOnClickListener { + startActivity(Intent(this@HomeActivity, WebhooksActivity::class.java)) + } + + ivLogout.setOnClickListener { + progressLayout.visibility = View.VISIBLE + webexViewModel.signOut() + } + + ivGetMe.setOnClickListener { + PersonDialogFragment().show(supportFragmentManager, getString(R.string.person_detail)) + } + + ivFeedback.setOnClickListener { + val fileUri = webexViewModel.getlogFileUri(false) + val recipient = "webex-mobile-sdk@cisco.com" + val subject = resources.getString(R.string.feedbackLogsSubject) + + val emailIntent = Intent().apply { + action = Intent.ACTION_SEND + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + type = "text/plain" +// data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(recipient)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_STREAM, fileUri) + } + + try { + startActivity(Intent.createChooser(emailIntent, "Send mail...")) + } + catch (e: Exception) { + Log.e(tag, "Send mail exception: $e") + } + } + + ivSetup.setOnClickListener { + startActivity(Intent(this@HomeActivity, SetupActivity::class.java)) + } + + ivExtras.setOnClickListener { + startActivity(Intent(this@HomeActivity, ExtrasActivity::class.java)) + } + } + + //used some delay because sometimes it gives empty stuff in personDetails + Handler().postDelayed(Runnable { + personViewModel.getMe() + }, 1000) + observeData() + showMessageIfCameFromNotification() + webexViewModel.setSpaceObserver() + webexViewModel.setMembershipObserver() + webexViewModel.setMessageObserver() + } + + override fun onBackPressed() { + (application as KitchenSinkApp).closeApplication() + } + + private fun showMessageIfCameFromNotification() { + + if("ACTION" == intent?.action){ + val messageId = intent?.getStringExtra(Constants.Bundle.MESSAGE_ID) + MessageDetailsDialogFragment.newInstance(messageId.orEmpty()).show(supportFragmentManager, "MessageDetailsDialogFragment") + } + } + + override fun onNewIntent(intent: Intent?) { + val messageId = intent?.getStringExtra(Constants.Bundle.MESSAGE_ID) + MessageDetailsDialogFragment.newInstance(messageId.orEmpty()).show(supportFragmentManager, "MessageDetailsDialogFragment") + super.onNewIntent(intent) + } + + private fun observeData() { + personViewModel.person.observe(this, Observer { person -> + person?.let { + webexViewModel.getFCMToken(it) + } + }) + } + + override fun onResume() { + super.onResume() + updateUCData() + webexViewModel.setGlobalIncomingListener() + } + + private fun updateUCData() { + Log.d(tag, "updateUCData isCUCMServerLoggedIn: ${webexViewModel.repository.isCUCMServerLoggedIn} ucServerConnectionStatus: ${webexViewModel.repository.ucServerConnectionStatus}") + if (webexViewModel.isCUCMServerLoggedIn) { + binding.ucLoginStatusTextView.visibility = View.VISIBLE + } else { + binding.ucLoginStatusTextView.visibility = View.GONE + } + + when (webexViewModel.ucServerConnectionStatus) { + UCLoginServerConnectionStatus.Connected -> { + binding.ucServerConnectionStatusTextView.text = resources.getString(R.string.phone_service_connected) + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + UCLoginServerConnectionStatus.Failed -> { + val text = resources.getString(R.string.phone_service_failed) + " " + webexViewModel.ucServerConnectionFailureReason + binding.ucServerConnectionStatusTextView.text = text + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + else -> { + binding.ucServerConnectionStatusTextView.visibility = View.GONE + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt new file mode 100644 index 0000000..2327f97 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/JWTWebexModule.kt @@ -0,0 +1,14 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val JWTWebexModule = module { + + factory { + Webex(androidApplication(), JWTAuthenticator()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt new file mode 100644 index 0000000..d5bc594 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkApp.kt @@ -0,0 +1,86 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.app.Application +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity +import com.ciscowebex.androidsdk.kitchensink.auth.loginModule +import com.ciscowebex.androidsdk.kitchensink.calling.callModule +import com.ciscowebex.androidsdk.kitchensink.extras.extrasModule +import com.ciscowebex.androidsdk.kitchensink.messaging.messagingModule +import com.ciscowebex.androidsdk.kitchensink.messaging.search.searchPeopleModule +import com.ciscowebex.androidsdk.kitchensink.person.personModule +import com.ciscowebex.androidsdk.kitchensink.search.searchModule +import com.ciscowebex.androidsdk.kitchensink.webhooks.webhooksModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.loadKoinModules +import org.koin.core.context.startKoin +import org.koin.core.context.unloadKoinModules + + +class KitchenSinkApp : Application(), LifecycleObserver { + + companion object { + lateinit var instance: KitchenSinkApp + private set + + fun applicationContext(): Context { + return instance.applicationContext + } + + fun get(): KitchenSinkApp { + return instance + } + + var inForeground: Boolean = false + } + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@KitchenSinkApp) + } + ProcessLifecycleOwner.get().getLifecycle().addObserver(this); + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + instance = this + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onMoveToForeground() { + // app moved to foreground + inForeground = true + } + + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onMoveToBackground() { + // app moved to background + inForeground = false + } + + fun closeApplication() { + android.os.Process.killProcess(android.os.Process.myPid()) + } + + fun loadKoinModules(type: LoginActivity.LoginType) { + when (type) { + LoginActivity.LoginType.JWT -> { + loadKoinModules(listOf(mainAppModule, webexModule, loginModule, JWTWebexModule, searchModule, callModule, messagingModule, personModule, searchPeopleModule, webhooksModule, extrasModule)) + } + else -> { + loadKoinModules(listOf(mainAppModule, webexModule, loginModule, OAuthWebexModule, searchModule, callModule, messagingModule, personModule, searchPeopleModule, webhooksModule, extrasModule)) + } + } + } + + fun unloadKoinModules() { + unloadKoinModules(listOf(mainAppModule, webexModule, loginModule, JWTWebexModule, OAuthWebexModule, searchModule, callModule, messagingModule, personModule, searchPeopleModule, webhooksModule, extrasModule)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt new file mode 100644 index 0000000..b012701 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/OAuthWebexModule.kt @@ -0,0 +1,23 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.auth.Authenticator +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.getEmailPref +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val OAuthWebexModule = module { + single (named("oAuth")) { + val clientId = BuildConfig.CLIENT_ID + val clientSecret = BuildConfig.CLIENT_SECRET + val redirectUri = BuildConfig.REDIRECT_URI + val email = getEmailPref(androidApplication()).orEmpty() + OAuthWebViewAuthenticator(clientId, clientSecret, redirectUri, email) + } + + factory { + Webex(androidApplication(), get(named("oAuth"))) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt new file mode 100644 index 0000000..97d8544 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexModule.kt @@ -0,0 +1,14 @@ +package com.ciscowebex.androidsdk.kitchensink + +import com.ciscowebex.androidsdk.kitchensink.calling.RingerManager +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val webexModule = module(createdAtStart = true) { + single { WebexRepository(get()) } + single { RingerManager(get()) } + + viewModel { + WebexViewModel(get(), get()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt new file mode 100644 index 0000000..e7ed0af --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt @@ -0,0 +1,296 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.WebexUCLoginDelegate +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.people.Person +import com.ciscowebex.androidsdk.space.Space +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.auth.PhoneServiceRegistrationFailureReason +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.membership.Membership +import com.ciscowebex.androidsdk.membership.MembershipObserver +import com.ciscowebex.androidsdk.message.MessageObserver +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.NotificationCallType +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.space.SpaceObserver + +class WebexRepository(val webex: Webex) : WebexUCLoginDelegate { + private val tag = "WebexRepository" + + enum class CallCap { + Audio_Only, + Audio_Video + } + + enum class CucmEvent { + ShowSSOLogin, + ShowNonSSOLogin, + OnUCLoginFailed, + OnUCLoggedIn, + OnUCServerConnectionStateChanged + } + + enum class LogLevel { + ALL, + VERBOSE, + INFO, + WARNING, + DEBUG, + ERROR, + NO + } + + enum class SpaceEvent { + Created, + Updated, + CallStarted, + CallEnded + } + + enum class MembershipEvent { + Created, + Updated, + Deleted, + MessageSeen + } + + enum class MessageEvent { + Received, + Edited, + Deleted, + MessageThumbnailUpdated + } + + enum class CallEvent { + DialCompleted, + DialFailed, + AnswerCompleted, + AnswerFailed, + AssociationCallCompleted, + AssociationCallFailed, + MeetingPinOrPasswordRequired + } + + data class CallLiveData(val event: CallEvent, + val call: Call? = null, + val sharingLabel: String? = null, + val errorMessage: String? = null, + val callMembershipEvent: CallObserver.CallMembershipChangedEvent? = null, + val mediaChangeEvent: CallObserver.MediaChangedEvent? = null, + val disconnectEvent: CallObserver.CallDisconnectedEvent? = null) {} + + var isAddedCall = false + var currentCallId: String? = null + var oldCallId: String? = null + var isSendingAudio = true + var doMuteAll = true + var incomingCallJoinedCallId: String? = null + var isLocalVideoMuted = true + var isRemoteVideoMuted = true + var isRemoteScreenShareON = false + var enableBgStreamtoggle = true + var enableBgConnectiontoggle = true + var enablePhoneStatePermission = true + var logFilter = LogLevel.ALL.name + var isConsoleLoggerEnabled = true + var callCapability: CallCap = CallCap.Audio_Video + var scalingMode: Call.VideoRenderMode = Call.VideoRenderMode.Fit + var compositedVideoLayout: MediaOption.CompositedVideoLayout = MediaOption.CompositedVideoLayout.FILMSTRIP + var streamMode: Phone.VideoStreamMode = Phone.VideoStreamMode.AUXILIARY + var isSpaceCallStarted = false + var spaceCallId:String? = null + + val participantMuteMap = hashMapOf() + var isCUCMServerLoggedIn = false + var ucServerConnectionStatus: UCLoginServerConnectionStatus = UCLoginServerConnectionStatus.Idle + var ucServerConnectionFailureReason: PhoneServiceRegistrationFailureReason = PhoneServiceRegistrationFailureReason.Unknown + + var _callMembershipsLiveData: MutableLiveData>? = null + var _muteAllLiveData: MutableLiveData? = null + var _cucmLiveData: MutableLiveData>? = null + var _callingLiveData: MutableLiveData? = null + var _startAssociationLiveData: MutableLiveData? = null + var _startShareLiveData: MutableLiveData? = null + var _stopShareLiveData: MutableLiveData? = null + + var _spaceEventLiveData: MutableLiveData>? = null + var _membershipEventLiveData: MutableLiveData>? = null + var _messageEventLiveData: MutableLiveData>? = null + + init { + webex.delegate = this + } + + fun clearCallData() { + isAddedCall = false + currentCallId = null + oldCallId = null + incomingCallJoinedCallId = null + isSendingAudio = true + doMuteAll = true + isLocalVideoMuted = true + isRemoteScreenShareON = false + isRemoteVideoMuted = true + + _callMembershipsLiveData = null + _muteAllLiveData = null + _callingLiveData = null + _startAssociationLiveData = null + _startShareLiveData = null + _stopShareLiveData = null + } + + fun clearSpaceData(){ + _spaceEventLiveData = null + } + + fun setSpaceObserver() { + webex.spaces.setSpaceObserver(object : SpaceObserver { + override fun onEvent(event: SpaceObserver.SpaceEvent) { + Log.d(tag, "onEvent: $event with actorID : ${event.getActorId().orEmpty()}") + when (event) { + is SpaceObserver.SpaceCallStarted -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.CallStarted, event.getSpaceId())) + isSpaceCallStarted = true + spaceCallId = event.getSpaceId() + } + is SpaceObserver.SpaceCallEnded -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.CallEnded, event.getSpaceId())) + isSpaceCallStarted = false + spaceCallId = null + } + is SpaceObserver.SpaceCreated -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.Created, event.getSpace())) + } + is SpaceObserver.SpaceUpdated -> { + _spaceEventLiveData?.postValue(Pair(SpaceEvent.Updated, event.getSpace())) + } + } + } + }) + } + + fun setMembershipObserver() { + webex.memberships.setMembershipObserver(object : MembershipObserver { + override fun onEvent(event: MembershipObserver.MembershipEvent?) { + Log.d(tag, "onMembershipEvent: $event") + when (event) { + is MembershipObserver.MembershipCreated -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.Created, event.getMembership())) + } + is MembershipObserver.MembershipUpdated -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.Updated, event.getMembership())) + } + is MembershipObserver.MembershipDeleted -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.Deleted, event.getMembership())) + } + is MembershipObserver.MembershipMessageSeen -> { + _membershipEventLiveData?.postValue(Pair(MembershipEvent.MessageSeen, event.getMembership())) + } + } + } + }) + } + + fun setMessageObserver() { + webex.messages.setMessageObserver(object : MessageObserver { + override fun onEvent(event: MessageObserver.MessageEvent) { + Log.d(tag, "onMessageEvent: $event") + when (event) { + is MessageObserver.MessageReceived -> { + _messageEventLiveData?.postValue(Pair(MessageEvent.Received, event.getMessage())) + } + is MessageObserver.MessageDeleted -> { + _messageEventLiveData?.postValue(Pair(MessageEvent.Deleted, event.getMessageId())) + } + is MessageObserver.MessageFileThumbnailsUpdated -> { + Log.d(tag, "onMessageFileThumbnailsUpdated triggered!") + _messageEventLiveData?.postValue(Pair(MessageEvent.MessageThumbnailUpdated, event.getFiles())) + } + is MessageObserver.MessageEdited -> { + _messageEventLiveData?.postValue(Pair(MessageEvent.Edited, event.getMessage())) + } + } + } + }) + } + + fun setIncomingListener() { + Log.d(tag, "setIncomingListener") + if (webex.phone.getIncomingCallListener() != null) { + webex.phone.setIncomingCallListener(object : Phone.IncomingCallListener { + override fun onIncomingCall(call: Call?) { + call?.let { + CallObjectStorage.addCallObject(it) + } ?: run { + Log.d(tag, "setIncomingCallListener Call object null") + } + } + }) + } + } + + fun getCall(callId: String): Call? { + return CallObjectStorage.getCallObject(callId) + } + + fun getCallIdByNotificationId(notificationId: String, callType: NotificationCallType): String { + return webex.getCallIdByNotificationId(notificationId, callType) + } + + fun stopShare(callId: String) { + getCall(callId)?.stopSharing(CompletionHandler { result -> + _stopShareLiveData?.postValue(result.isSuccessful) + }) + } + + fun getSpace(spaceId: String, handler: CompletionHandler){ + webex.spaces.get(spaceId, handler) + } + + fun getPerson(personId: String, handler: CompletionHandler){ + webex.people.get(personId, handler) + } + + fun listMessages(spaceId: String, handler: CompletionHandler>){ + webex.messages.list(spaceId, null, 10000, null, handler) + } + + // Callbacks + override fun showUCSSOLoginView(ssoUrl: String) { + _cucmLiveData?.postValue(Pair(CucmEvent.ShowSSOLogin, ssoUrl)) + Log.d(tag, "showUCSSOLoginView") + } + + override fun showUCNonSSOLoginView() { + _cucmLiveData?.postValue(Pair(CucmEvent.ShowNonSSOLogin, "")) + Log.d(tag, "showUCNonSSOLoginView") + } + + override fun onUCLoginFailed() { + _cucmLiveData?.postValue(Pair(CucmEvent.OnUCLoginFailed, "")) + Log.d(tag, "onUCLoginFailed") + isCUCMServerLoggedIn = false + } + + override fun onUCLoggedIn() { + _cucmLiveData?.postValue(Pair(CucmEvent.OnUCLoggedIn, "")) + Log.d(tag, "onUCLoggedIn") + isCUCMServerLoggedIn = true + } + + override fun onUCServerConnectionStateChanged(status: UCLoginServerConnectionStatus, failureReason: PhoneServiceRegistrationFailureReason) { + _cucmLiveData?.postValue(Pair(CucmEvent.OnUCServerConnectionStateChanged, "")) + Log.d(tag, "onUCServerConnectionStateChanged status: $status failureReason: $failureReason") + ucServerConnectionStatus = status + ucServerConnectionFailureReason = failureReason + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt new file mode 100644 index 0000000..d11c5b3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt @@ -0,0 +1,752 @@ +package com.ciscowebex.androidsdk.kitchensink + +import android.app.AlertDialog +import android.app.Notification +import android.net.Uri +import android.util.Log +import android.view.View +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.firebase.RegisterTokenService +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.WebexError +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.Task +import com.google.firebase.messaging.FirebaseMessaging +import org.json.JSONObject +import com.ciscowebex.androidsdk.phone.CallAssociationType +import com.ciscowebex.androidsdk.auth.PhoneServiceRegistrationFailureReason +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.kitchensink.calling.CallObserverInterface +import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.phone.AdvancedSetting +import com.ciscowebex.androidsdk.phone.AuxStream + + +class WebexViewModel(val webex: Webex, val repository: WebexRepository) : BaseViewModel() { + private val tag = "WebexViewModel" + + var _callMembershipsLiveData = MutableLiveData>() + val _muteAllLiveData = MutableLiveData() + val _cucmLiveData = MutableLiveData>() + val _callingLiveData = MutableLiveData() + val _startAssociationLiveData = MutableLiveData() + val _startShareLiveData = MutableLiveData() + val _stopShareLiveData = MutableLiveData() + val _setCompositeLayoutLiveData = MutableLiveData>() + val _setRemoteVideoRenderModeLiveData = MutableLiveData>() + + var callMembershipsLiveData: LiveData> = _callMembershipsLiveData + val muteAllLiveData: LiveData = _muteAllLiveData + val cucmLiveData: LiveData> = _cucmLiveData + val callingLiveData: LiveData = _callingLiveData + val startAssociationLiveData: LiveData = _startAssociationLiveData + val startShareLiveData: LiveData = _startShareLiveData + val stopShareLiveData: LiveData = _stopShareLiveData + val setCompositeLayoutLiveData: LiveData> = _setCompositeLayoutLiveData + val setRemoteVideoRenderModeLiveData: LiveData> = _setRemoteVideoRenderModeLiveData + + private val _incomingListenerLiveData = MutableLiveData() + val incomingListenerLiveData: LiveData = _incomingListenerLiveData + + private val _signOutListenerLiveData = MutableLiveData() + val signOutListenerLiveData: LiveData = _signOutListenerLiveData + + private val _tokenLiveData = MutableLiveData>() + val tokenLiveData: LiveData> = _tokenLiveData + + var selfPersonId: String? = null + var compositedLayoutState = MediaOption.CompositedVideoLayout.NOT_SUPPORTED + + var callObserverInterface: CallObserverInterface? = null + + var callCapability: WebexRepository.CallCap + get() = repository.callCapability + set(value) { + repository.callCapability = value + } + + var scalingMode: Call.VideoRenderMode + get() = repository.scalingMode + set(value) { + repository.scalingMode = value + } + + var compositedVideoLayout: MediaOption.CompositedVideoLayout + get() = repository.compositedVideoLayout + set(value) { + repository.compositedVideoLayout = value + } + + var streamMode: Phone.VideoStreamMode + get() = repository.streamMode + set(value) { + repository.streamMode = value + } + + var isAddedCall: Boolean + get() = repository.isAddedCall + set(value) { + repository.isAddedCall = value + } + + var currentCallId: String? + get() = repository.currentCallId + set(value) { + repository.currentCallId = value + } + + var oldCallId: String? + get() = repository.oldCallId + set(value) { + repository.oldCallId = value + } + + var isSendingAudio: Boolean + get() = repository.isSendingAudio + set(value) { + repository.isSendingAudio = value + } + + var doMuteAll: Boolean + get() = repository.doMuteAll + set(value) { + repository.doMuteAll = value + } + + var incomingCallJoinedCallId: String? + get() = repository.incomingCallJoinedCallId + set(value) { + repository.incomingCallJoinedCallId = value + } + + var isLocalVideoMuted: Boolean + get() = repository.isLocalVideoMuted + set(value) { + repository.isLocalVideoMuted = value + } + + var isRemoteVideoMuted: Boolean + get() = repository.isRemoteVideoMuted + set(value) { + repository.isRemoteVideoMuted = value + } + + var isCUCMServerLoggedIn: Boolean + get() = repository.isCUCMServerLoggedIn + set(value) { + repository.isCUCMServerLoggedIn = value + } + + var ucServerConnectionStatus: UCLoginServerConnectionStatus + get() = repository.ucServerConnectionStatus + set(value) { + repository.ucServerConnectionStatus = value + } + + var ucServerConnectionFailureReason: PhoneServiceRegistrationFailureReason + get() = repository.ucServerConnectionFailureReason + set(value) { + repository.ucServerConnectionFailureReason = value + } + + var isRemoteScreenShareON: Boolean + get() = repository.isRemoteScreenShareON + set(value) { + repository.isRemoteScreenShareON = value + } + + var enableBgStreamtoggle: Boolean + get() = repository.enableBgStreamtoggle + set(value) { + repository.enableBgStreamtoggle = value + } + + var enableBgConnectiontoggle: Boolean + get() = repository.enableBgConnectiontoggle + set(value) { + repository.enableBgConnectiontoggle = value + } + + var enablePhoneStatePermission: Boolean + get() = repository.enablePhoneStatePermission + set(value) { + repository.enablePhoneStatePermission = value + } + + var logFilter: String + get() = repository.logFilter + set(value) { + repository.logFilter = value + } + + var isConsoleLoggerEnabled: Boolean + get() = repository.isConsoleLoggerEnabled + set(value) { + repository.isConsoleLoggerEnabled = value + } + + init { + repository._callMembershipsLiveData = _callMembershipsLiveData + repository._cucmLiveData = _cucmLiveData + repository._muteAllLiveData = _muteAllLiveData + repository._callingLiveData = _callingLiveData + repository._startAssociationLiveData = _startAssociationLiveData + repository._startShareLiveData = _startShareLiveData + repository._stopShareLiveData = _stopShareLiveData + } + + fun setLogLevel(logLevel: String) { + var level: Webex.LogLevel = Webex.LogLevel.ALL + when (logLevel) { + WebexRepository.LogLevel.ALL.name -> level = Webex.LogLevel.ALL + WebexRepository.LogLevel.VERBOSE.name -> level = Webex.LogLevel.VERBOSE + WebexRepository.LogLevel.INFO.name -> level = Webex.LogLevel.INFO + WebexRepository.LogLevel.WARNING.name -> level = Webex.LogLevel.WARNING + WebexRepository.LogLevel.DEBUG.name -> level = Webex.LogLevel.DEBUG + WebexRepository.LogLevel.ERROR.name -> level = Webex.LogLevel.ERROR + WebexRepository.LogLevel.NO.name -> level = Webex.LogLevel.NO + } + webex.setLogLevel(level) + } + + fun enableConsoleLogger(enable: Boolean) { + webex.enableConsoleLogger(enable) + } + + override fun onCleared() { + repository.clearCallData() + } + + fun setSpaceObserver() { + repository.setSpaceObserver() + } + + fun setMembershipObserver() { + repository.setMembershipObserver() + } + + fun setMessageObserver() { + repository.setMessageObserver() + } + + fun setIncomingListener() { + webex.phone.setIncomingCallListener(object : Phone.IncomingCallListener { + override fun onIncomingCall(call: Call?) { + call?.let { + CallObjectStorage.addCallObject(it) + _incomingListenerLiveData.postValue(it) + setCallObserver(it) + } ?: run { + Log.d(tag, "setIncomingCallListener Call object null") + } + } + }) + } + + fun setFCMIncomingListenerObserver(callId: String) { + val call = CallObjectStorage.getCallObject(callId) + call?.let { + setCallObserver(it) + } + } + + fun setGlobalIncomingListener() { + repository.setIncomingListener() + } + + fun signOut() { + webex.authenticator?.deauthorize(CompletionHandler { result -> + result?.let { + _signOutListenerLiveData.postValue(it.isSuccessful) + if (!it.isSuccessful) { + Log.d(tag, "Logut error : ${it.error?.errorMessage}") + } + } + }) + } + + fun dial(input: String, option: MediaOption) { + webex.phone.dial(input, option, CompletionHandler { result -> + Log.d(tag, "Omnius: onCallEvent CallStateChanged") + if (result.isSuccessful) { + result.data?.let { _call -> + CallObjectStorage.addCallObject(_call) + currentCallId = _call.getCallId() + setCallObserver(_call) + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.DialCompleted, _call)) + } + } else { + result.error?.let { errorCode -> + if (errorCode.errorCode == WebexError.ErrorCode.HOST_PIN_OR_MEETING_PASSWORD_REQUIRED.code) { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.MeetingPinOrPasswordRequired, null)) + } else { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.DialFailed, null, null, result.error?.errorMessage)) + } + } ?: run { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.DialFailed, null, null, result.error?.errorMessage)) + } + } + }) + } + + fun answer(call: Call, mediaOption: MediaOption) { + call.answer(mediaOption, CompletionHandler { result -> + if (result.isSuccessful) { + result.data.let { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AnswerCompleted, call)) + } + } else { + _callingLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AnswerFailed, null, null, result.error?.errorMessage)) + } + }) + } + + private fun setCallObserver(call: Call) { + call.setObserver(object : CallObserver { + override fun onConnected(call: Call?) { + callObserverInterface?.onConnected(call) + } + + override fun onRinging(call: Call?) { + callObserverInterface?.onRinging(call) + } + + override fun onWaiting(call: Call?, reason: Call.WaitReason?) { + Log.d(tag, "CallObserver onWaiting reason: $reason") + callObserverInterface?.onWaiting(call) + } + + override fun onDisconnected(event: CallObserver.CallDisconnectedEvent?) { + Log.d(tag, "CallObserver onDisconnected event: $event") + callObserverInterface?.onDisconnected(call, event) + } + + override fun onInfoChanged(call: Call?) { + callObserverInterface?.onInfoChanged(call) + } + + override fun onMediaChanged(event: CallObserver.MediaChangedEvent?) { + Log.d(tag, "CallObserver OnMediaChanged event: $event") + callObserverInterface?.onMediaChanged(call, event) + } + + override fun onCallMembershipChanged(event: CallObserver.CallMembershipChangedEvent?) { + Log.d(tag, "CallObserver onCallMembershipChanged event: $event") + callObserverInterface?.onCallMembershipChanged(call, event) + getParticipants(event?.getCall()?.getCallId().orEmpty()) + } + + override fun onScheduleChanged(call: Call?) { + callObserverInterface?.onScheduleChanged(call) + } + }) + } + + fun setReceivingVideo(call: Call, receiving: Boolean) { + call.setReceivingVideo(receiving) + } + + fun setReceivingAudio(call: Call, receiving: Boolean) { + call.setReceivingAudio(receiving) + } + + fun setReceivingSharing(call: Call, receiving: Boolean) { + call.setReceivingSharing(receiving) + } + + fun muteSelfVideo(callId: String, doMute: Boolean) { + getCall(callId)?.setSendingVideo(!doMute) + } + + fun getCall(callId: String): Call? { + return repository.getCall(callId) + } + + fun muteAllParticipantAudio(callId: String) { + if (!isSendingAudio) { + muteSelfAudio(callId) + } + Log.d(tag, "postParticipantData muteAllParticipantAudio: $doMuteAll") + getCall(callId)?.muteAllParticipantAudio(doMuteAll) + } + + fun muteParticipant(callId: String, participantId: String) { + repository.participantMuteMap[participantId]?.let { doMute -> + if (participantId == selfPersonId) { + muteSelfAudio(callId) + } else { + getCall(callId)?.muteParticipantAudio(participantId, doMute) + } + } + } + + fun muteSelfAudio(callId: String) { + Log.d(tag, "muteSelfAudio isSendingAudio: $isSendingAudio") + getCall(callId)?.setSendingAudio(!isSendingAudio) + } + + fun startShare(callId: String) { + getCall(callId)?.startSharing(CompletionHandler { result -> + _startShareLiveData.postValue(result.isSuccessful) + }) + } + + fun startShare(callId: String, notification: Notification?, notificationId: Int) { + getCall(callId)?.startSharing(notification, notificationId, CompletionHandler { result -> + _startShareLiveData.postValue(result.isSuccessful) + }) + } + + fun setSendingSharing(callId: String, value: Boolean) { + getCall(callId)?.setSendingSharing(value) + } + + fun stopShare(callId: String) { + getCall(callId)?.stopSharing(CompletionHandler { result -> + _stopShareLiveData.postValue(result.isSuccessful) + }) + } + + fun sendFeedback(callId: String, rating: Int, comment: String) { + getCall(callId)?.sendFeedback(rating, comment) + } + + fun sendDTMF(callId: String, keys: String) { + getCall(callId)?.sendDTMF(keys, CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "sendDTMF successful") + } else { + Log.d(tag, "sendDTMF error: ${result.error?.errorMessage}") + } + }) + } + + fun hangup(callId: String) { + getCall(callId)?.hangup(CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "hangup successful") + } else { + Log.d(tag, "hangup error: ${result.error?.errorMessage}") + } + }) + } + + fun rejectCall(callId: String) { + getCall(callId)?.reject(CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "rejectCall successful") + } else { + Log.d(tag, "rejectCall error: ${result.error?.errorMessage}") + } + }) + } + + fun holdCall(callId: String) { + val callInfo = getCall(callId) + val isOnHold = callInfo?.isOnHold() ?: false + Log.d(tag, "holdCall isOnHold = $isOnHold") + callInfo?.holdCall(!isOnHold) + } + + fun isOnHold(callId: String) = getCall(callId)?.isOnHold() + + fun getParticipants(_callId: String) { + val callParticipants = getCall(_callId)?.getMemberships() ?: ArrayList() + repository._callMembershipsLiveData?.postValue(callParticipants) + + callParticipants.forEach { + repository.participantMuteMap[it.getPersonId()] = it.isSendingAudio() + } + } + + fun setUCDomainServerUrl(ucDomain: String, serverUrl: String) { + webex.setUCDomainServerUrl(ucDomain, serverUrl) + } + + fun setCUCMCredential(username: String, password: String) { + webex.setCUCMCredential(username, password) + } + + fun isUCLoggedIn(): Boolean { + return webex.isUCLoggedIn() + } + + fun getUCServerConnectionStatus(): UCLoginServerConnectionStatus { + return webex.getUCServerConnectionStatus() + } + + fun startAssociatedCall(callId: String, dialNumber: String, associationType: CallAssociationType, audioCall: Boolean) { + getCall(callId)?.startAssociatedCall(dialNumber, associationType, audioCall, CompletionHandler { result -> + Log.d(tag, "startAssociatedCall Lambda") + if (result.isSuccessful) { + Log.d(tag, "startAssociatedCall Lambda isSuccessful") + result.data?.let { + setCallObserver(it) + _startAssociationLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AssociationCallCompleted, it)) + } + } else { + Log.d(tag, "startAssociatedCall Lambda isSuccessful 5") + _startAssociationLiveData.postValue(WebexRepository.CallLiveData(WebexRepository.CallEvent.AssociationCallFailed, null, null, result.error?.errorMessage)) + Log.d(tag, "startAssociatedCall Lambda isSuccessful 6") + } + }) + } + + fun transferCall(fromCallId: String, toCallId: String) { + getCall(fromCallId)?.transferCall(toCallId) + } + + fun mergeCalls(currentCallId: String, targetCallId: String) { + getCall(currentCallId)?.mergeCalls(targetCallId) + } + + fun getlogFileUri(includelastRunLog: Boolean = false): Uri { + return webex.getlogFileUri(includelastRunLog) + } + + fun getFCMToken(personModel: PersonModel) { + FirebaseMessaging.getInstance().token + .addOnCompleteListener(object : OnCompleteListener { + override fun onComplete(task: Task) { + if (!task.isSuccessful) { + Log.w(tag, "Fetching FCM registration token failed", task.exception) + return + } + + // Get new FCM registration token + val token: String? = task.result + Log.d(tag, "$token") + sendTokenToServer(Pair(token, personModel)) + } + }) + } + + private fun sendTokenToServer(it: Pair) { + val json = JSONObject() + json.put("token", it.first) + json.put("personId", it.second.personId) + json.put("email", it.second.emailList) + RegisterTokenService().execute(json.toString()) + } + + fun postParticipantData(data: List?) { + synchronized(this) { + _callMembershipsLiveData.postValue(data) + + var isRemoteSendingAudio = false + data?.forEach { + if (it.getPersonId() != selfPersonId) { + if (it.isSendingAudio()) { + isRemoteSendingAudio = true + } + } + repository.participantMuteMap[it.getPersonId()] = it.isSendingAudio() + } + + Log.d(tag, "postParticipantData hasMutedAll: $isRemoteSendingAudio") + doMuteAll = isRemoteSendingAudio + repository._muteAllLiveData?.postValue(doMuteAll) + } + } + + fun getHeader(state: CallMembership.State): String { + return when(state) { + CallMembership.State.UNKNOWN -> "Not in meeting" + CallMembership.State.JOINED -> "In meeting" + CallMembership.State.WAITING -> "In lobby" + CallMembership.State.IDLE -> "Idle" + CallMembership.State.DECLINED -> "Call declined" + CallMembership.State.LEFT -> "Left meeting" + CallMembership.State.NOTIFIED -> "Notified" + } + } + + fun setVideoMaxTxFPSSetting(fps: Int) { + webex.phone.setAdvancedSetting(AdvancedSetting.VideoMaxTxFPS(fps) as AdvancedSetting<*>) + } + + fun setVideoEnableDecoderMosaicSetting(value: Boolean) { + webex.phone.setAdvancedSetting(AdvancedSetting.VideoEnableDecoderMosaic(value) as AdvancedSetting<*>) + } + + fun setShareMaxCaptureFPSSetting(fps: Int) { + webex.phone.setAdvancedSetting(AdvancedSetting.ShareMaxCaptureFPS(fps) as AdvancedSetting<*>) + } + + fun setVideoEnableCamera2Setting(value: Boolean) { + webex.phone.setAdvancedSetting(AdvancedSetting.VideoEnableCamera2(value) as AdvancedSetting<*>) + } + + fun switchAudioMode(mode: Call.AudioOutputMode) { + getCall(currentCallId.orEmpty())?.switchAudioOutput(mode) + } + + fun enableAudioBNR(value: Boolean) { + webex.phone.enableAudioBNR(value) + } + + fun isAudioBNREnable(): Boolean { + return webex.phone.isAudioBNREnable() + } + + fun setAudioBNRMode(mode: Phone.AudioBRNMode) { + webex.phone.setAudioBNRMode(mode) + } + + fun getAudioBNRMode(): Phone.AudioBRNMode { + return webex.phone.getAudioBNRMode() + } + + fun setDefaultFacingMode(mode: Phone.FacingMode) { + webex.phone.setDefaultFacingMode(mode) + } + + fun getDefaultFacingMode() : Phone.FacingMode { + return webex.phone.getDefaultFacingMode() + } + + fun disableVideoCodecActivation() { + webex.phone.disableVideoCodecActivation() + } + + fun getVideoCodecLicense(): String { + return webex.phone.getVideoCodecLicense() + } + + fun getVideoCodecLicenseURL(): String { + return webex.phone.getVideoCodecLicenseURL() + } + + fun requestVideoCodecActivation(builder: AlertDialog.Builder) { + webex.phone.requestVideoCodecActivation(builder, CompletionHandler { result -> + Log.d(tag, "requestVideoCodecActivation result action: ${result.data}") + }) + } + + fun setHardwareAccelerationEnabled(enable: Boolean) { + webex.phone.setHardwareAccelerationEnabled(enable) + } + + fun setVideoMaxRxBandwidth(bandwidth: Int) { + webex.phone.setVideoMaxRxBandwidth(bandwidth) + } + + fun setVideoMaxTxBandwidth(bandwidth: Int) { + webex.phone.setVideoMaxTxBandwidth(bandwidth) + } + + fun setSharingMaxRxBandwidth(bandwidth: Int) { + webex.phone.setSharingMaxRxBandwidth(bandwidth) + } + + fun setAudioMaxRxBandwidth(bandwidth: Int) { + webex.phone.setAudioMaxRxBandwidth(bandwidth) + } + + fun startPreview(preView: View) { + webex.phone.startPreview(preView) + } + + fun stopPreview() { + webex.phone.stopPreview() + } + + fun enableBackgroundConnection(enable: Boolean) { + webex.phone.enableBackgroundConnection(enable) + } + + fun enableBackgroundStream(enable: Boolean) { + webex.phone.enableBackgroundStream(enable) + } + + fun enableAskingReadPhoneStatePermission(enable: Boolean) { + webex.phone.enableAskingReadPhoneStatePermission(enable) + } + + fun getVideoRenderViews(callId: String): Pair { + return getCall(callId)?.getVideoRenderViews() ?: Pair(null, null) + } + + fun setVideoRenderViews(callId: String, localVideoView: View, remoteVideoView: View) { + getCall(callId)?.setVideoRenderViews(Pair(localVideoView, remoteVideoView)) + } + + fun getSharingRenderView(callId: String): View? { + return getCall(callId)?.getSharingRenderView() + } + + fun setSharingRenderView(callId: String, view: View?) { + getCall(callId)?.setSharingRenderView(view) + } + + fun setRemoteVideoRenderMode(callId: String, mode: Call.VideoRenderMode) { + getCall(callId)?.setRemoteVideoRenderMode(mode, CompletionHandler { + it.let { + if (it.isSuccessful) { + Log.d(tag, "setRemoteVideoRenderMode successful") + _setRemoteVideoRenderModeLiveData.postValue(Pair(true, "")) + } else { + Log.d(tag, "setRemoteVideoRenderMode failed: ${it.error?.errorMessage}") + _setRemoteVideoRenderModeLiveData.postValue(Pair(false, it.error?.errorMessage ?: "")) + } + } + }) + } + + fun letIn(callId: String, callMembership: CallMembership) { + getCall(callId)?.letIn(callMembership) + } + + fun setVideoStreamMode(mode: Phone.VideoStreamMode) { + webex.phone.setVideoStreamMode(mode) + } + + fun getVideoStreamMode(): Phone.VideoStreamMode { + return webex.phone.getVideoStreamMode() + } + + fun getCompositedLayout(): MediaOption.CompositedVideoLayout { + return getCall(currentCallId.orEmpty())?.getCompositedVideoLayout() ?: MediaOption.CompositedVideoLayout.NOT_SUPPORTED + } + + fun setCompositedLayout(compositedLayout: MediaOption.CompositedVideoLayout) { + compositedLayoutState = compositedLayout + getCall(currentCallId.orEmpty())?.setCompositedVideoLayout(compositedLayout, CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "setCompositedLayout Lambda isSuccessful") + _setCompositeLayoutLiveData.postValue(Pair(true, "")) + } else { + Log.d(tag, "setCompositedLayout Lambda error: ${result.error?.errorMessage}") + _setCompositeLayoutLiveData.postValue(Pair(false, result.error?.errorMessage ?: "")) + } + }) + } + + fun closeAuxStream(view: View) { + getCall(currentCallId.orEmpty())?.closeAuxStream(view) + } + + fun getAuxStream(view: View): AuxStream? { + return getCall(currentCallId.orEmpty())?.getAuxStream(view) + } + + fun getAvailableAuxStreamCount(): Int { + return getCall(currentCallId.orEmpty())?.getAvailableAuxStreamCount() ?: 0 + } + + fun getOpenedAuxStreamCount(): Int { + return getCall(currentCallId.orEmpty())?.getOpenedAuxStreamCount() ?: 0 + } + + fun openAuxStream(view: View) { + getCall(currentCallId.orEmpty())?.openAuxStream(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt new file mode 100644 index 0000000..566df3c --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/JWTLoginActivity.kt @@ -0,0 +1,82 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.HomeActivity +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityLoginWithTokenBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.viewmodel.ext.android.viewModel + +class JWTLoginActivity : AppCompatActivity() { + + lateinit var binding: ActivityLoginWithTokenBinding + private val loginViewModel: LoginViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_login_with_token) + .also { binding = it } + .apply { + title.text = getString(R.string.login_jwt) + progressLayout.visibility = View.VISIBLE + loginButton.setOnClickListener { + binding.loginFailedTextView.visibility = View.GONE + if (jwtTokenText.text.isEmpty()) { + showDialogWithMessage(this@JWTLoginActivity, R.string.error_occurred, resources.getString(R.string.jwt_login_token_empty_error)) + } + else { + binding.loginButton.visibility = View.GONE + progressLayout.visibility = View.VISIBLE + val token = jwtTokenText.text.toString() + loginViewModel.loginWithJWT(token) + } + } + + loginViewModel.isAuthorized.observe(this@JWTLoginActivity, Observer { isAuthorized -> + progressLayout.visibility = View.GONE + isAuthorized?.let { + if (it) { + onLoggedIn() + } else { + onLoginFailed() + } + } + }) + + loginViewModel.isAuthorizedCached.observe(this@JWTLoginActivity, Observer { isAuthorizedCached -> + progressLayout.visibility = View.GONE + isAuthorizedCached?.let { + if (it) { + onLoggedIn() + } else { + jwtTokenText.visibility = View.VISIBLE + loginButton.visibility = View.VISIBLE + loginFailedTextView.visibility = View.GONE + } + } + }) + + loginViewModel.initialize() + } + } + + override fun onBackPressed() { + (application as KitchenSinkApp).closeApplication() + } + + private fun onLoggedIn() { + startActivity(Intent(this, HomeActivity::class.java)) + finish() + } + + private fun onLoginFailed() { + binding.loginButton.visibility = View.VISIBLE + binding.loginFailedTextView.visibility = View.VISIBLE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt new file mode 100644 index 0000000..4b6e88a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginActivity.kt @@ -0,0 +1,114 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityLoginBinding +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.clearEmailPref +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.getLoginTypePref +import com.ciscowebex.androidsdk.kitchensink.utils.SharedPrefUtils.saveEmailPref +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogForInputEmail + +class LoginActivity : AppCompatActivity() { + lateinit var binding: ActivityLoginBinding + + enum class LoginType(var value: String) { + OAuth("OAuth"), + JWT("JWT") + } + + private var loginTypeCalled = LoginType.OAuth + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_login) + .also { binding = it } + .apply { + + val type = getLoginTypePref(this@LoginActivity) + + when (type) { + LoginType.JWT.value -> { + loginTypeCalled = LoginType.JWT + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, JWTLoginActivity::class.java)) + finish() + } + LoginType.OAuth.value -> { + loginTypeCalled = LoginType.OAuth + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, OAuthWebLoginActivity::class.java)) + finish() + } + } + + btnJwtLogin.setOnClickListener { + buttonClicked(LoginType.JWT) + } + + btnOauthLogin.setOnClickListener { + buttonClicked(LoginType.OAuth) + } + + } + } + + private fun buttonClicked(type: LoginType) { + loginTypeCalled = type + toggleButtonsVisibility(true) + + when (type) { + LoginType.JWT -> { + startJWTActivity() + } + LoginType.OAuth -> { + showEmailDialog(type) + } + } + } + + private fun toggleButtonsVisibility(hide: Boolean) { + if (hide) { + binding.loginButtonLayout.visibility = View.GONE + binding.loginFailedTextView.visibility = View.GONE + binding.btnJwtLogin.visibility = View.GONE + } else { + binding.loginButtonLayout.visibility = View.VISIBLE + binding.loginFailedTextView.visibility = View.GONE + binding.btnJwtLogin.visibility = View.VISIBLE + } + } + + private fun startOAuthActivity() { + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, OAuthWebLoginActivity::class.java)) + finish() + } + + private fun startJWTActivity() { + (application as KitchenSinkApp).loadKoinModules(loginTypeCalled) + startActivity(Intent(this@LoginActivity, JWTLoginActivity::class.java)) + finish() + } + + private fun showEmailDialog(type: LoginType) { + showDialogForInputEmail(this, getString(R.string.enter_user_email_address), onPositiveButtonClick = { dialog: DialogInterface, email: String -> + when (type) { + LoginType.OAuth -> { + saveEmailPref(this, email) + startOAuthActivity() + } + } + dialog.dismiss() + }, onNegativeButtonClick = { dialog: DialogInterface, _: Int -> + clearEmailPref(this) + toggleButtonsVisibility(false) + dialog.dismiss() + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt new file mode 100644 index 0000000..b323422 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginModule.kt @@ -0,0 +1,11 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val loginModule = module { + + viewModel { LoginViewModel(get(), get()) } + + single { LoginRepository() } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt new file mode 100644 index 0000000..2841125 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginRepository.kt @@ -0,0 +1,48 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.webkit.WebView +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class LoginRepository() { + fun authorizeOAuth(loginWebview: WebView, oAuthAuthenticator: OAuthWebViewAuthenticator): Observable { + return Single.create { emitter -> + oAuthAuthenticator.authorize(loginWebview, CompletionHandler { result -> + if (result.error != null) { + emitter.onError(Throwable(result.error?.errorMessage)) + } else { + emitter.onSuccess(result.isSuccessful) + } + }) + }.toObservable() + } + + fun initialize(webex: Webex): Observable { + return Single.create { emitter -> + webex.initialize(CompletionHandler { result -> + if (result.error != null) { + emitter.onError(Throwable(result.error?.errorMessage)) + } else { + emitter.onSuccess(result.isSuccessful) + } + }) + }.toObservable() + } + + fun loginWithJWT(token: String, jwtAuthenticator: JWTAuthenticator): Observable { + return Single.create { emitter -> + jwtAuthenticator.authorize(token, CompletionHandler { result -> + if (result.error != null) { + emitter.onError(Throwable(result.error?.errorMessage)) + } else { + emitter.onSuccess(result.isSuccessful) + } + }) + }.toObservable() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt new file mode 100644 index 0000000..e484c48 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/LoginViewModel.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.webkit.WebView +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import com.ciscowebex.androidsdk.auth.OAuthWebViewAuthenticator +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers + +class LoginViewModel(private val webex: Webex, private val loginRepository: LoginRepository) : BaseViewModel() { + private val _isAuthorized = MutableLiveData() + val isAuthorized: LiveData = _isAuthorized + + private val _isAuthorizedCached = MutableLiveData() + val isAuthorizedCached: LiveData = _isAuthorizedCached + + private val _errorData = MutableLiveData() + val errorData : LiveData = _errorData + + fun authorizeOAuth(loginWebview: WebView) { + val oAuthAuthenticator = webex.authenticator as? OAuthWebViewAuthenticator + oAuthAuthenticator?.let { auth -> + loginRepository.authorizeOAuth(loginWebview, auth).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _isAuthorized.postValue(it) + }, { + _errorData.postValue(it.message) + }).autoDispose() + } ?: run { + _isAuthorized.postValue(false) + } + } + + fun initialize() { + loginRepository.initialize(webex).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _isAuthorizedCached.postValue(it) + }, {_isAuthorizedCached.postValue(false)}).autoDispose() + } + + fun loginWithJWT(token: String) { + val jwtAuthenticator = webex.authenticator as? JWTAuthenticator + jwtAuthenticator?.let { auth -> + loginRepository.loginWithJWT(token, auth).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _isAuthorized.postValue(it) + }, {_isAuthorized.postValue(false)}).autoDispose() + } ?: run { + _isAuthorized.postValue(false) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt new file mode 100644 index 0000000..fff804c --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/auth/OAuthWebLoginActivity.kt @@ -0,0 +1,84 @@ +package com.ciscowebex.androidsdk.kitchensink.auth + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.HomeActivity +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityOauthBinding +import org.koin.android.viewmodel.ext.android.viewModel + +class OAuthWebLoginActivity : AppCompatActivity() { + + lateinit var binding: ActivityOauthBinding + private val loginViewModel: LoginViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_oauth) + .also { binding = it } + .apply { + progressLayout.visibility = View.VISIBLE + + loginViewModel.isAuthorized.observe(this@OAuthWebLoginActivity, Observer { isAuthorized -> + progressLayout.visibility = View.GONE + isAuthorized?.let { + if (it) { + onLoggedIn() + } else { + onLoginFailed() + } + } + }) + + loginViewModel.isAuthorizedCached.observe(this@OAuthWebLoginActivity, Observer { isAuthorizedCached -> + progressLayout.visibility = View.GONE + isAuthorizedCached?.let { + if (it) { + onLoggedIn() + } else { + appBarLayout.visibility = View.GONE + binding.exitButton.visibility = View.GONE + loginFailedTextView.visibility = View.GONE + loginWebview.visibility = View.VISIBLE + loginViewModel.authorizeOAuth(loginWebview) + } + } + }) + + loginViewModel.errorData.observe(this@OAuthWebLoginActivity, Observer { errorMessage -> + onLoginFailed(errorMessage) + }) + + exitButton.setOnClickListener { + // close application as user needs to reload koin modules, currently unloading and reloading of koin modules doesn't work + (application as KitchenSinkApp).closeApplication() + } + + loginViewModel.initialize() + } + } + + override fun onBackPressed() { + (application as KitchenSinkApp).closeApplication() + } + + private fun onLoggedIn() { + startActivity(Intent(this, HomeActivity::class.java)) + finish() + } + + private fun onLoginFailed(failureMessage: String = getString(R.string.login_failed)) { + Log.d("auth : ", "onLoginFailed, updating ui") + binding.loginWebview.visibility = View.GONE + binding.appBarLayout.visibility = View.VISIBLE + binding.exitButton.visibility = View.VISIBLE + binding.loginFailedTextView.visibility = View.VISIBLE + binding.loginFailedTextView.text = failureMessage + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt new file mode 100644 index 0000000..d32f612 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt @@ -0,0 +1,108 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityCallBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + + +class CallActivity : BaseActivity() { + + lateinit var binding: ActivityCallBinding + + companion object { + fun getOutgoingIntent(context: Context, callerName: String): Intent { + val intent = Intent(context, CallActivity::class.java) + intent.putExtra(Constants.Intent.CALLING_ACTIVITY_ID, 0) + intent.putExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID, callerName) + return intent + } + fun getIncomingIntent(context: Context): Intent { + val intent = Intent(context, CallActivity::class.java) + intent.putExtra(Constants.Intent.CALLING_ACTIVITY_ID, 1) + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "CallActivity" + DataBindingUtil.setContentView(this, R.layout.activity_call) + .also { binding = it } + .apply { + val callingActivity = intent.getIntExtra(Constants.Intent.CALLING_ACTIVITY_ID, 0) + + if (callingActivity == 0) { + val callerId = intent.getStringExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID) + val fragment = supportFragmentManager.findFragmentById(R.id.containerFragment) as CallControlsFragment + + callerId?.let { + fragment.dialOutgoingCall(callerId) + } + } else if (intent.action == Constants.Action.WEBEX_CALL_ACTION){ + intent?.getStringExtra(Constants.Intent.CALL_ID) ?.let { callId -> + handleIncomingWebexCallFromFCM(callId) + } + } + } + } + + private fun handleIncomingWebexCallFromFCM(callId: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.containerFragment) + if (fragment is CallControlsFragment){ + fragment.handleFCMIncomingCall(callId) + } else { + Log.d(CallActivity::class.java.name, "fragment is null") + } + } + + override fun onBackPressed() { + val fragment = supportFragmentManager.findFragmentById(R.id.containerFragment) + if ( (fragment is CallControlsFragment) && (fragment.needBackPressed())) { + fragment.onBackPressed() + } else { + super.onBackPressed() + } + } + + fun alertDialog(shouldFinishActivity: Boolean, message: String) { + val builder = AlertDialog.Builder(this) + builder.setTitle(resources.getString(R.string.call_failed)) + builder.setMessage(message) + + builder.setPositiveButton("OK") { _, _ -> + if(shouldFinishActivity) finish() + } + + builder.show() + } + + private fun toBeShownOnLockScreen() { + window.addFlags( + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true) + setShowWhenLocked(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + ) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + toBeShownOnLockScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt new file mode 100644 index 0000000..3d1e0f7 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt @@ -0,0 +1,137 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetCallOptionsBinding +import com.ciscowebex.androidsdk.phone.Call +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.Phone + +class CallBottomSheetFragment(val receivingVideoClickListener: (Call?) -> Unit, + val receivingAudioClickListener: (Call?) -> Unit, + val receivingSharingClickListener: (Call?) -> Unit, + val scalingModeClickListener: (Call?) -> Unit, + val compositeStreamLayoutClickListener: (Call?) -> Unit): BottomSheetDialogFragment() { + companion object { + val TAG = "MessageActionBottomSheetFragment" + } + + private lateinit var binding: BottomSheetCallOptionsBinding + var call: Call? = null + lateinit var scalingModeValue: Call.VideoRenderMode + lateinit var compositeLayoutValue: MediaOption.CompositedVideoLayout + lateinit var streamMode: Phone.VideoStreamMode + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetCallOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + var receivingVideoText = getString(R.string.receiving_video) + val receivingVideoStatus = call?.isReceivingVideo() ?: false + receivingVideoText += if (receivingVideoStatus) { + " - " + getString(R.string.receiving_on) + } else { + " - " + getString(R.string.receiving_off) + } + receivingVideo.text = receivingVideoText + + receivingVideo.setOnClickListener { + dismiss() + receivingVideoClickListener(call) + } + + var receivingAudioText = getString(R.string.receiving_audio) + val receiving = call?.isReceivingAudio() ?: false + receivingAudioText += if (receiving) { + " - " + getString(R.string.receiving_on) + } else { + " - " + getString(R.string.receiving_off) + } + receivingAudio.text = receivingAudioText + + receivingAudio.setOnClickListener { + dismiss() + receivingAudioClickListener(call) + } + + var receivingSharingText = getString(R.string.receiving_sharing) + val sharing = call?.isReceivingSharing() ?: false + receivingSharingText += if (sharing) { + " - " + getString(R.string.receiving_on) + } else { + " - " + getString(R.string.receiving_off) + } + receivingSharing.text = receivingSharingText + + receivingSharing.setOnClickListener { + dismiss() + receivingSharingClickListener(call) + } + + var scalingTypeText = getString(R.string.scaling_mode) + + scalingTypeText += when (scalingModeValue) { + Call.VideoRenderMode.Fit -> { + " - " + getString(R.string.scaling_mode_fit) + } + Call.VideoRenderMode.CropFill -> { + " - " + getString(R.string.scaling_mode_cropFill) + } + Call.VideoRenderMode.StretchFill -> { + " - " + getString(R.string.scaling_mode_stretchFill) + } + Call.VideoRenderMode.NotSupported -> { + " - " + getString(R.string.scaling_mode_not_supported) + } + else -> { + " - " + getString(R.string.scaling_mode_unknown) + } + } + scalingMode.text = scalingTypeText + scalingMode.setOnClickListener { + dismiss() + scalingModeClickListener(call) + } + + var compositeLayoutText = getString(R.string.composite_stream) + + compositeLayoutText += when (compositeLayoutValue) { + MediaOption.CompositedVideoLayout.FILMSTRIP -> { + " - " + getString(R.string.composite_stream_filmstrip) + } + MediaOption.CompositedVideoLayout.GRID -> { + " - " + getString(R.string.composite_stream_grid) + } + MediaOption.CompositedVideoLayout.SINGLE -> { + " - " + getString(R.string.composite_stream_single) + } + MediaOption.CompositedVideoLayout.NOT_SUPPORTED -> { + " - " + getString(R.string.composite_stream_not_supported) + } + else -> { + " - " + getString(R.string.composite_stream_unknown) + } + } + + if (streamMode == Phone.VideoStreamMode.COMPOSITED) { + compositeStream.isEnabled = true + compositeStream.alpha = 1.0f + } else { + compositeStream.isEnabled = false + compositeStream.alpha = 0.5f + compositeLayoutText = getString(R.string.video_stream_mode_multi) + } + + compositeStream.text = compositeLayoutText + compositeStream.setOnClickListener { + dismiss() + compositeStreamLayoutClickListener(call) + } + + cancel.setOnClickListener { dismiss() } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt new file mode 100644 index 0000000..1beb156 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt @@ -0,0 +1,1717 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.app.Activity +import android.app.AlertDialog +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Pair +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnClickListener +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.WebexViewModel +import com.ciscowebex.androidsdk.kitchensink.calling.participants.ParticipantsFragment +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCallControlsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemCallMeetingBinding +import com.ciscowebex.androidsdk.kitchensink.person.PersonViewModel +import com.ciscowebex.androidsdk.kitchensink.utils.AudioManagerUtils +import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.CallAssociationType +import com.ciscowebex.androidsdk.phone.CallSchedule +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.phone.MediaRenderView +import com.ciscowebex.androidsdk.phone.MultiStreamObserver +import com.ciscowebex.androidsdk.phone.AuxStream +import org.koin.android.ext.android.inject +import android.widget.EditText +import android.widget.Toast +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogCreateSpaceBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogEnterMeetingPinBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import java.util.Date + + +class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface { + private val TAG = "CallControlsFragment" + private lateinit var webexViewModel: WebexViewModel + private lateinit var binding: FragmentCallControlsBinding + private var callFailed = false + private var isIncomingActivity = false + private var callingActivity = 0 + private var audioManagerUtils: AudioManagerUtils? = null + var onLockSelfVideoMutedState = true + var onLockRemoteSharingStateON = false + val SHARE_SCREEN_FOREGROUND_SERVICE_NOTIFICATION_ID = 0xabc61 + private val ringerManager: RingerManager by inject() + private val personViewModel : PersonViewModel by inject() + private lateinit var callOptionsBottomSheetFragment: CallBottomSheetFragment + private lateinit var incomingInfoAdapter: IncomingInfoAdapter + private val mAuxStreamViewMap: HashMap = HashMap() + private var callerId: String = "" + + enum class ShareButtonState { + OFF, + ON, + DISABLED + } + + class AuxStreamViewHolder(var item: View) { + var mediaRenderView: MediaRenderView = item.findViewById(R.id.view_video) + var textView: TextView = item.findViewById(R.id.name) + var viewAvatar: ImageView = item.findViewById(R.id.view_avatar) + var remoteBorder: RelativeLayout = item.findViewById(R.id.remote_border) + } + + companion object { + const val REQUEST_CODE = 1212 + const val TAG = "CallControlsFragment" + private const val CALLER_ID = "callerId" + const val MEDIA_PROJECTION_REQUEST = 1 + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return DataBindingUtil.inflate(LayoutInflater.from(context), + R.layout.fragment_call_controls, container, false).also { binding = it }.apply { + webexViewModel = (activity as? CallActivity)?.webexViewModel!! + Log.d(TAG, "CallControlsFragment onCreateView webexViewModel: $webexViewModel") + setUpViews() + observerCallLiveData() + initAudioManager() + }.root + } + + private fun initAudioManager() { + audioManagerUtils = AudioManagerUtils(requireContext()) + } + + override fun onResume() { + Log.d(TAG, "CallControlsFragment onResume") + super.onResume() + checkIsOnHold() + webexViewModel.currentCallId?.let { + onVideoStreamingChanged(it) + } + } + + private fun checkIsOnHold() { + val isOnHold = webexViewModel.currentCallId?.let { webexViewModel.isOnHold(it) } + binding.ibHoldCall.isSelected = isOnHold ?: false + } + + private fun getMediaOption(isModerator: Boolean = false, pin: String = ""): MediaOption { + val mediaOption: MediaOption + if (webexViewModel.callCapability == WebexRepository.CallCap.Audio_Only) { + mediaOption = MediaOption.audioOnly() + } else { + mediaOption = MediaOption.audioVideoSharing(Pair(binding.localView, binding.remoteView), binding.screenShareView) + // MediaOption.audioVideo(Pair(binding.localView, binding.remoteView)) + } + mediaOption.setModerator(isModerator) + mediaOption.setPin(pin) + return mediaOption + } + + fun dialOutgoingCall(callerId: String, isModerator: Boolean = false, pin: String = "") { + Log.d(TAG, "dialOutgoingCall") + this.callerId = callerId + webexViewModel.dial(callerId, getMediaOption(isModerator, pin)) + } + + private fun checkLicenseAPIs() { + val license = webexViewModel.getVideoCodecLicense() + Log.d(TAG, "checkLicenseAPIs license $license") + val URL = webexViewModel.getVideoCodecLicenseURL() + Log.d(TAG, "checkLicenseAPIs license URL $URL") + webexViewModel.requestVideoCodecActivation(AlertDialog.Builder(activity)) + } + + override fun onConnected(call: Call?) { + Log.d(TAG, "CallObserver onConnected : " + call?.getCallId()) + onCallConnected(call?.getCallId().orEmpty()) + ringerManager.stopRinger(if (isIncomingActivity) RingerManager.RingerType.Incoming else RingerManager.RingerType.Outgoing) + webexViewModel.sendDTMF(call?.getCallId().orEmpty(), "2") + webexViewModel.sendFeedback(call?.getCallId().orEmpty(), 5, "Testing Comments SDK-v3") + webexViewModel.setShareMaxCaptureFPSSetting(5) + } + + override fun onRinging(call: Call?) { + Log.d(TAG, "CallObserver OnRinging : " + call?.getCallId()) + ringerManager.startRinger(RingerManager.RingerType.Outgoing) + } + + override fun onWaiting(call: Call?) { + Log.d(TAG, "CallObserver OnWaiting : " + call?.getCallId()) + } + + override fun onDisconnected(call: Call?, event: CallObserver.CallDisconnectedEvent?) { + Log.d(TAG, "CallObserver onDisconnected : " + call?.getCallId()) + + var callFailed = false + var callEnded = false + var localClose = false + + event?.let { _event -> + val _call = _event.getCall() + when (_event) { + is CallObserver.LocalLeft -> { + Log.d(TAG, "CallObserver LocalLeft") + localClose = true + } + is CallObserver.LocalDecline -> { + Log.d(TAG, "CallObserver LocalDecline") + } + is CallObserver.LocalCancel -> { + Log.d(TAG, "CallObserver LocalCancel") + localClose = true + } + is CallObserver.RemoteLeft -> { + Log.d(TAG, "CallObserver RemoteLeft") + } + is CallObserver.RemoteDecline -> { + Log.d(TAG, "CallObserver RemoteDecline") + } + is CallObserver.RemoteCancel -> { + Log.d(TAG, "CallObserver RemoteCancel") + } + is CallObserver.OtherConnected -> { + Log.d(TAG, "CallObserver OtherConnected") + } + is CallObserver.OtherDeclined -> { + Log.d(TAG, "CallObserver OtherDeclined") + } + is CallObserver.CallErrorEvent -> { + Log.d(TAG, "CallObserver CallErrorEvent") + callFailed = true + } + is CallObserver.CallEnded -> { + Log.d(TAG, "CallObserver CallEnded") + callEnded = true + } + else -> {} + } + } + + when { + callFailed -> { + onCallFailed(call?.getCallId().orEmpty()) + } + callEnded -> { + onCallTerminated(call?.getCallId().orEmpty()) + } + else -> { + val schedules = call?.getSchedules() + if (localClose) { + if (schedules == null && !isIncomingActivity) { + /** + * Taken care of space call when local left + */ + onCallTerminated(call?.getCallId().orEmpty()) + } else { + onCallDisconnected(call) + } + } + } + } + + ringerManager.stopRinger(if (isIncomingActivity) RingerManager.RingerType.Incoming else RingerManager.RingerType.Outgoing) + } + + override fun onInfoChanged(call: Call?) { + Log.d(TAG, "CallObserver onInfoChanged : " + call?.getCallId()) + + Handler(Looper.getMainLooper()).post { + call?.let { _call -> + binding.ibHoldCall.isSelected = _call.isOnHold() + } + } + } + + override fun onMediaChanged(call: Call?, event: CallObserver.MediaChangedEvent?) { + Log.d(TAG, "CallObserver OnMediaChanged") + + event?.let { _event -> + val call = _event.getCall() + when (_event) { + is CallObserver.RemoteSendingVideoEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged RemoteSendingVideoEvent: ${_event.isSending()}") + webexViewModel.isRemoteVideoMuted = !_event.isSending() + onVideoStreamingChanged(call?.getCallId().orEmpty()) + } + is CallObserver.SendingVideo -> { + Log.d(TAG, "CallObserver OnMediaChanged SendingVideo: ${_event.isSending()}") + webexViewModel.isLocalVideoMuted = !_event.isSending() + onVideoStreamingChanged(call?.getCallId().orEmpty()) + } + is CallObserver.ReceivingVideo -> { + Log.d(TAG, "CallObserver OnMediaChanged ReceivingVideo: ${_event.isReceiving()}") + webexViewModel.isRemoteVideoMuted = !_event.isReceiving() + onVideoStreamingChanged(call?.getCallId().orEmpty()) + } + is CallObserver.RemoteSendingAudioEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged RemoteSendingAudioEvent: ${_event.isSending()}") + audioEventChanged(null, call, null, _event.isSending()) + } + is CallObserver.SendingAudio -> { + Log.d(TAG, "CallObserver OnMediaChanged SendingAudio: ${_event.isSending()}") + audioEventChanged(null, call, _event.isSending()) + } + is CallObserver.ReceivingAudio -> { + Log.d(TAG, "CallObserver OnMediaChanged ReceivingAudio: ${_event.isReceiving()}") + audioEventChanged(null, call, null, _event.isReceiving()) + } + is CallObserver.RemoteSendingSharingEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged RemoteSendingSharingEvent: ${_event.isSending()}") + onScreenShareStateChanged(call?.getCallId().orEmpty(), call?.getScreenShareLabel().orEmpty()) + onScreenShareVideoStreamInUseChanged(call?.getCallId().orEmpty()) + } + is CallObserver.SendingSharingEvent -> { + Log.d(TAG, "CallObserver OnMediaChanged SendingSharingEvent: ${_event.isSending()}") + onScreenShareStateChanged(call?.getCallId().orEmpty(), call?.getScreenShareLabel().orEmpty()) + onScreenShareVideoStreamInUseChanged(call?.getCallId().orEmpty()) + } + is CallObserver.ReceivingSharing -> { + Log.d(TAG, "CallObserver OnMediaChanged ReceivingSharing: ${_event.isReceiving()}") + onScreenShareStateChanged(call?.getCallId().orEmpty(), call?.getScreenShareLabel().orEmpty()) + onScreenShareVideoStreamInUseChanged(call?.getCallId().orEmpty()) + } + is CallObserver.CameraSwitched -> { + Log.d(TAG, "CallObserver CameraSwitched") + } + is CallObserver.LocalVideoViewSizeChanged -> { + Log.d(TAG, "CallObserver LocalVideoViewSizeChanged") + } + is CallObserver.RemoteVideoViewSizeChanged -> { + Log.d(TAG, "CallObserver RemoteVideoViewSizeChanged") + } + is CallObserver.LocalSharingViewSizeChanged -> { + Log.d(TAG, "CallObserver LocalSharingViewSizeChanged") + } + is CallObserver.RemoteSharingViewSizeChanged -> { + Log.d(TAG, "CallObserver RemoteSharingViewSizeChanged") + } + is CallObserver.ActiveSpeakerChangedEvent -> { + Log.d(TAG, "CallObserver ActiveSpeakerChangedEvent from: ${_event.from()}, To: ${_event.to()}") + } + else -> {} + } + } + } + + override fun onCallMembershipChanged(call: Call?, event: CallObserver.CallMembershipChangedEvent?) { + Log.d(TAG, "CallObserver OnCallMembershipEvent") + + event?.let { membershipEvent -> + val call = membershipEvent.getCall() + val callMembership = membershipEvent.getCallMembership() + when (membershipEvent) { + is CallObserver.MembershipJoinedEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipJoinedEvent") + audioEventChanged(callMembership, call) + } + is CallObserver.MembershipLeftEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipLeftEvent") + } + is CallObserver.MembershipDeclinedEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipDeclinedEvent") + } + is CallObserver.MembershipSendingVideoEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipSendingVideoEvent") + } + is CallObserver.MembershipSendingAudioEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipSendingAudioEvent") + } + is CallObserver.MembershipSendingSharingEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipSendingSharingEvent") + } + is CallObserver.MembershipWaitingEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipWaitingEvent") + } + is CallObserver.MembershipAudioMutedControlledEvent -> { + Log.d(TAG, "CallObserver OnCallMembershipEvent MembershipAudioMutedControlledEvent") + audioEventChanged(callMembership, call) + } + else -> {} + } + } + } + + override fun onScheduleChanged(call: Call?) { + Log.d(TAG, "CallObserver OnScheduleChanged : " + call?.getCallId()) + schedulesChanged(call) + } + + private fun observerCallLiveData() { + + personViewModel.person.observe(viewLifecycleOwner, Observer { person -> + person?.let { + webexViewModel.selfPersonId = it.personId + } + }) + + webexViewModel.startShareLiveData.observe(viewLifecycleOwner, Observer { status -> + status?.let { + if (it) { + Log.d(TAG, "startShareLiveData success") + } else { + updateScreenShareButtonState(ShareButtonState.OFF) + Log.d(TAG, "User cancelled screen request") + } + } + }) + + webexViewModel.stopShareLiveData.observe(viewLifecycleOwner, Observer { status -> + status?.let { + if (it) { + Log.d(TAG, "stopShareLiveData success") + } else { + Log.d(TAG, "stopShareLiveData Failed") + } + } + }) + + webexViewModel.setCompositeLayoutLiveData.observe(viewLifecycleOwner, Observer { result -> + result?.let { + if (it.first) { + Log.d(TAG, "setCompositeLayoutLiveData success") + webexViewModel.compositedVideoLayout = webexViewModel.compositedLayoutState + } else { + Log.d(TAG, "setCompositeLayoutLiveData Failed") + } + } + }) + + webexViewModel.setRemoteVideoRenderModeLiveData.observe(viewLifecycleOwner, Observer { result -> + result?.let { + if (it.first) { + Log.d(TAG, "setRemoteVideoRenderModeLiveData success") + } else { + Log.d(TAG, "setRemoteVideoRenderModeLiveData Failed: ${it.second}") + showDialogWithMessage(requireContext(), R.string.scaling_mode, it.second) + } + } + }) + + webexViewModel.callingLiveData.observe(viewLifecycleOwner, Observer { + it?.let { + val event = it.event + val call = it.call + val sharingLabel = it.sharingLabel + val errorMessage = it.errorMessage + + when (event) { + WebexRepository.CallEvent.DialCompleted -> { + Log.d(tag, "callingLiveData DIAL_COMPLETED callerId: ${call?.getCallId()}") + onCallJoined(call) + handleCUCMControls(call) + } + WebexRepository.CallEvent.DialFailed -> { + val callActivity = activity as CallActivity? + callActivity?.alertDialog(true, errorMessage ?: "") + } + WebexRepository.CallEvent.AnswerCompleted -> { + Log.d(TAG, "answer Lambda callInfo Id: ${call?.getCallId()}") + onCallJoined(call) + handleCUCMControls(null) + } + WebexRepository.CallEvent.AnswerFailed -> { + Log.d(TAG, "answer Lambda failed $errorMessage") + callEndedUIUpdate(call?.getCallId().orEmpty()) + } + WebexRepository.CallEvent.MeetingPinOrPasswordRequired -> { + Log.d(TAG, "CallObserver MeetingPinOrPasswordRequired : " + call?.getCallId()) + onMeetingHostPinError() + } + else -> {} + } + } + }) + + webexViewModel.startAssociationLiveData.observe(viewLifecycleOwner, Observer { + it?.let { + val event = it.event + val call = it.call + val errorMessage = it.errorMessage + + when (event) { + WebexRepository.CallEvent.AssociationCallCompleted -> { + webexViewModel.isAddedCall = true + webexViewModel.oldCallId = webexViewModel.currentCallId + webexViewModel.currentCallId = call?.getCallId()?:"" + + call?.let { _call -> + CallObjectStorage.addCallObject(_call) + } + onCallJoined(call) + handleCUCMControls(call) + Log.d(tag, "startAssociatedCall currentCallId = ${webexViewModel.currentCallId}, oldCallId = ${webexViewModel.oldCallId}") + } + WebexRepository.CallEvent.AssociationCallFailed -> { + Log.d(TAG, "startAssociatedCall Lambda failed $errorMessage") + val callActivity = activity as CallActivity? + callActivity?.alertDialog(false, resources.getString(R.string.start_associated_call_failed)) + } + else -> {} + } + } + }) + } + + private fun onMeetingHostPinError() { + showDialogWithMessage(requireContext(), getString(R.string.meeting_error), getString(R.string.are_you_host), cancelable = false, + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + handleMeetingPinInput(true) + + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + handleMeetingPinInput(false) + }) + } + + private fun handleMeetingPinInput(isHost: Boolean) { + val builder: androidx.appcompat.app.AlertDialog.Builder = androidx.appcompat.app.AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.calling) + builder.setCancelable(false) + val hint = if (isHost) R.string.enter_host_key else R.string.enter_meeting_pin + + DialogEnterMeetingPinBinding.inflate(layoutInflater) + .apply { + builder.setView(this.root) + pinTitleLabel.text = getString(hint) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + if (pinTitleEditText.text.isEmpty()) { + val error = if (isHost) getString(R.string.host_key_required) else getString(R.string.meeting_pin_required) + Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + + dialOutgoingCall(callerId, isHost, pinTitleEditText.text.toString()) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + } + + private fun audioEventChanged(callMembership: CallMembership?, call: Call?, isSendingAudio: Boolean? = null, isRemoteSendingAudio: Boolean? = null) { + Handler(Looper.getMainLooper()).post { + callMembership?.let { member -> + if (member.getPersonId() == webexViewModel.selfPersonId) { + val audioMuted = !member.isSendingAudio() + + webexViewModel.isSendingAudio = member.isSendingAudio() + if (audioMuted) { + showMutedIcon(true) + } else { + showMutedIcon(false) + } + } + } ?: run { + isSendingAudio?.let { audio -> + val audioMuted = !audio + + webexViewModel.isSendingAudio = audio + if (audioMuted) { + showMutedIcon(true) + } else { + showMutedIcon(false) + } + } + } + + webexViewModel.postParticipantData(call?.getMemberships()) + showCallHeader(call?.getCallId().orEmpty()) + } + } + + private fun schedulesChanged(call: Call?) { + val schedules= call?.getSchedules() + schedules?.let { + for (item in schedules) { + incomingInfoAdapter.info.forEach { model -> + if ((model is MeetingInfoModel) && (model.meetingId == item.getId())) { + val infoModel = MeetingInfoModel.convertToMeetingInfoModel(call, item) + incomingInfoAdapter.info.remove(model) + incomingInfoAdapter.info.add(infoModel) + incomingInfoAdapter.notifyDataSetChanged() + return + } + } + } + } ?: run { + //Canceled meeting + incomingInfoAdapter.info.forEach { model -> + if (model is MeetingInfoModel) { + incomingInfoAdapter.info.remove(model) + } + } + incomingInfoAdapter.notifyDataSetChanged() + } + } + + private fun handleCUCMControls(call: Call?) { + Handler(Looper.getMainLooper()).post { + Log.d(TAG, "handleCUCMControls isAddedCall = ${webexViewModel.isAddedCall}") + webexViewModel.currentCallId?.let { callId -> + + var _call = call + + if (_call == null) { + _call = webexViewModel.getCall(callId) + } + + _call?.let { + when { + it.isCUCMCall() && webexViewModel.isAddedCall -> { + binding.ibTransferCall.visibility = View.VISIBLE + binding.ibMerge.visibility = View.VISIBLE + binding.ibAdd.visibility = View.INVISIBLE + binding.ibVideo.visibility = View.INVISIBLE + } + !it.isCUCMCall() -> { + binding.ibAdd.visibility = View.GONE + binding.ibTransferCall.visibility = View.INVISIBLE + } + } + } + } + } + } + + private fun showMutedIcon(showMuted: Boolean) { + binding.ibMute.isSelected = showMuted + } + + override fun onDestroyView() { + super.onDestroyView() + webexViewModel.callObserverInterface = null + } + + private fun setUpViews() { + Log.d(TAG, "setUpViews fragment") + personViewModel.getMe() + videoViewState(true) + + webexViewModel.callObserverInterface = this + + webexViewModel.enableBackgroundStream(webexViewModel.enableBgStreamtoggle) + webexViewModel.enableAudioBNR(true) + webexViewModel.setAudioBNRMode(Phone.AudioBRNMode.HP) + webexViewModel.setDefaultFacingMode(Phone.FacingMode.USER) + + webexViewModel.setVideoMaxTxFPSSetting(5) + webexViewModel.setVideoEnableCamera2Setting(true) + webexViewModel.setVideoEnableDecoderMosaicSetting(true) + + webexViewModel.setHardwareAccelerationEnabled(true) + webexViewModel.setVideoMaxRxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_720P.getValue()) + webexViewModel.setVideoMaxTxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_720P.getValue()) + webexViewModel.setSharingMaxRxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_SESSION.getValue()) + webexViewModel.setAudioMaxRxBandwidth(Phone.DefaultBandwidth.MAX_BANDWIDTH_AUDIO.getValue()) + + webexViewModel.setVideoStreamMode(webexViewModel.streamMode) + + val incomingCallEvent: (Call?) -> Unit = { call -> + Log.d(tag, "incomingCallEvent") + webexViewModel.currentCallId = call?.getCallId().orEmpty() + } + + val incomingCallPickEvent: (Call?) -> Unit = { call -> + Log.d(tag, "incomingCallPickEvent") + call?.let { + webexViewModel.answer(it, getMediaOption()) + } + } + + val incomingCallCancelEvent: (Call?) -> Unit = { call -> + Log.d(tag, "incomingCallEndEvent") + endIncomingCall(call?.getCallId().orEmpty()) + } + + incomingInfoAdapter = IncomingInfoAdapter(incomingCallEvent, incomingCallPickEvent, incomingCallCancelEvent) + binding.incomingRecyclerView.adapter = incomingInfoAdapter + + callOptionsBottomSheetFragment = CallBottomSheetFragment({ call -> receivingVideoListener(call) }, + { call -> receivingAudioListener(call) }, + { call -> receivingSharingListener(call) }, + { call -> scalingModeClickListener(call) }, + { call -> compositeStreamLayoutClickListener(call) }) + + callingActivity = activity?.intent?.getIntExtra(Constants.Intent.CALLING_ACTIVITY_ID, 0)!! + if (callingActivity == 1) { + isIncomingActivity = true + binding.mainContentLayout.visibility = View.GONE + binding.incomingCallHeader.visibility = View.VISIBLE + incomingLayoutState(false) + + webexViewModel.setIncomingListener() + webexViewModel.incomingListenerLiveData.observe(viewLifecycleOwner, Observer { + it?.let { + ringerManager.startRinger(RingerManager.RingerType.Incoming) + onIncomingCall(it) + } + }) + } else { + isIncomingActivity = false + binding.incomingCallHeader.visibility = View.GONE + incomingLayoutState(true) + + binding.callingHeader.text = getString(R.string.calling) + val callerId = activity?.intent?.getStringExtra(Constants.Intent.OUTGOING_CALL_CALLER_ID) + binding.tvName.text = callerId + } + + binding.ibMute.setOnClickListener(this) + binding.ibParticipants.setOnClickListener(this) + binding.ibSpeaker.setOnClickListener(this) + binding.ibAdd.setOnClickListener(this) + binding.ibTransferCall.setOnClickListener(this) + binding.ibHoldCall.setOnClickListener(this) + binding.ivCancelCall.setOnClickListener(this) + binding.ibVideo.setOnClickListener(this) + binding.ibSwapCamera.setOnClickListener(this) + binding.ibMerge.setOnClickListener(this) + binding.ibScreenShare.setOnClickListener(this) + binding.mainContentLayout.setOnClickListener(this) + binding.ibMoreOption.setOnClickListener(this) + + initAddedCallControls() + + } + + override fun onClick(v: View?) { + webexViewModel.currentCallId?.let { callId -> + when (v) { + binding.ibMute -> { + webexViewModel.muteSelfAudio(callId) + } + binding.ibParticipants -> { + val dialog = ParticipantsFragment.newInstance(callId) + dialog.show(childFragmentManager, ParticipantsFragment::javaClass.name) + } + binding.ibSpeaker -> { + toggleSpeaker(v) + } + binding.ibAdd -> { + //while associating a call, existing call needs to be put on hold + webexViewModel.holdCall(callId) + startActivityForResult(DialerActivity.getIntent(requireContext()), REQUEST_CODE) + } + binding.ibTransferCall -> { + transferCall() + initAddedCallControls() + } + binding.ibMerge -> { + mergeCalls() + initAddedCallControls() + } + binding.ibHoldCall -> { + webexViewModel.holdCall(callId) + } + binding.ivCancelCall -> { + endCall() + } + binding.ibVideo -> { + muteSelfVideo(!webexViewModel.isLocalVideoMuted) + } + binding.ibSwapCamera -> { + val call = webexViewModel.getCall(webexViewModel.currentCallId.orEmpty()) + + call?.let { + val mode = it.getFacingMode() + + if (mode == Phone.FacingMode.ENVIROMENT) { + it.setFacingMode(Phone.FacingMode.USER) + } else { + it.setFacingMode(Phone.FacingMode.ENVIROMENT) + } + } + } + binding.ibScreenShare -> { + shareScreen() + } + binding.mainContentLayout -> { + mainContentLayoutClickListener() + } + binding.ibMoreOption -> { + webexViewModel.currentCallId?.let { + showBottomSheet(webexViewModel.getCall(it)) + } + } + else -> { + } + } + } + } + + private fun mainContentLayoutClickListener() { + Log.d(TAG, "mainContentLayoutClickListener") + if (binding.incomingRecyclerView.visibility == View.VISIBLE) { + return + } + + if (binding.controlGroup.visibility == View.VISIBLE) { + binding.controlGroup.visibility = View.GONE + } else { + binding.controlGroup.visibility = View.VISIBLE + } + } + + private fun screenShareButtonVisibilityState() { + webexViewModel.currentCallId?.let { + val canShare = webexViewModel.getCall(it)?.canShare() ?: false + Log.d(TAG, "CallControlsFragment screenShareButtonVisibilityState canShare: $canShare") + + if (canShare) { + binding.ibScreenShare.visibility = View.VISIBLE + } else { + binding.ibScreenShare.visibility = View.INVISIBLE + } + + } ?: run { + binding.ibScreenShare.visibility = View.INVISIBLE + } + } + + private fun updateScreenShareButtonState(state: ShareButtonState) { + when (state) { + ShareButtonState.OFF -> { + binding.ibScreenShare.isEnabled = true + binding.ibScreenShare.alpha = 1.0f + binding.ibScreenShare.background = ContextCompat.getDrawable(requireActivity(), R.drawable.screen_sharing_default) + } + ShareButtonState.ON -> { + binding.ibScreenShare.isEnabled = true + binding.ibScreenShare.alpha = 1.0f + binding.ibScreenShare.background = ContextCompat.getDrawable(requireActivity(), R.drawable.screen_sharing_active) + } + ShareButtonState.DISABLED -> { + binding.ibScreenShare.isEnabled = false + binding.ibScreenShare.alpha = 0.5f + } + } + } + + private fun isLocalSharing(callId: String): Boolean { + val call = webexViewModel.getCall(callId) + return call?.isSendingSharing() ?: false + } + + private fun isReceivingSharing(callId: String): Boolean { + val call = webexViewModel.getCall(callId) + return call?.isReceivingSharing() ?: false + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String{ + val chan = NotificationChannel(channelId, + channelName, NotificationManager.IMPORTANCE_NONE) + chan.lightColor = Color.BLUE + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + val service = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + service?.createNotificationChannel(chan) + return channelId + } + + private fun buildScreenShareForegroundServiceNotification(): Notification { + val contentId = R.string.notification_start_share_foreground_text + + val channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel("screen_share_service_v3_sdk", "Background Screen Share Service v3 SDK") + } else { "" } + + + val notificationBuilder = + NotificationCompat.Builder(requireContext(), channelId) + .setSmallIcon(R.drawable.app_notification_icon) + .setContentTitle(getString(R.string.notification_share_foreground_title)) + .setContentText(getString(contentId)) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setTicker(getString(contentId)) + .setDefaults(Notification.DEFAULT_SOUND) + + return notificationBuilder.build() + } + + private fun shareScreen() { + Log.d(TAG, "shareScreen") + + webexViewModel.currentCallId?.let { + val isSharing = isLocalSharing(it) + Log.d(TAG, "shareScreen isSharing: $isSharing") + if (!isSharing) { + updateScreenShareButtonState(ShareButtonState.DISABLED) + if (requireContext().applicationInfo.targetSdkVersion >= 29) { + webexViewModel.startShare(webexViewModel.currentCallId.orEmpty(), buildScreenShareForegroundServiceNotification(), SHARE_SCREEN_FOREGROUND_SERVICE_NOTIFICATION_ID) + } else { + webexViewModel.startShare(webexViewModel.currentCallId.orEmpty()) + } + } else { + updateScreenShareButtonState(ShareButtonState.DISABLED) + webexViewModel.currentCallId?.let { id -> webexViewModel.stopShare(id) } + } + } + } + + fun needBackPressed(): Boolean { + if (isIncomingActivity && + webexViewModel.currentCallId == null) { + return false + } + + return true + } + + fun onBackPressed() { + endCall() + } + + private fun endCall() { + if (isIncomingActivity) { + if (binding.incomingRecyclerView.visibility == View.VISIBLE) { + activity?.finish() + } else { + endIncomingCall() + } + } else { + webexViewModel.currentCallId?.let { + webexViewModel.hangup(it) + } ?: run { + activity?.finish() + } + } + } + + private fun incomingLayoutState(hide: Boolean) { + if (hide) { + binding.incomingRecyclerView.visibility = View.GONE + binding.mainContentLayout.visibility = View.VISIBLE + } else { + binding.incomingRecyclerView.visibility = View.VISIBLE + binding.mainContentLayout.visibility = View.GONE + + if (incomingInfoAdapter.info.size > 0) { + for (model in incomingInfoAdapter.info) { + model.isEnabled = true + } + incomingInfoAdapter.notifyDataSetChanged() + } + } + } + + private fun videoViewTextColorState(hidden: Boolean) { + var hide = hidden + if (hide && webexViewModel.isRemoteScreenShareON) { + hide = false + } + + if (hide) { + binding.callingHeader.setTextColor(ContextCompat.getColor(requireContext(), R.color.black)) + binding.tvName.setTextColor(ContextCompat.getColor(requireContext(), R.color.black)) + } else { + binding.callingHeader.setTextColor(ContextCompat.getColor(requireContext(), R.color.white)) + binding.tvName.setTextColor(ContextCompat.getColor(requireContext(), R.color.white)) + } + } + + private fun localVideoViewState(toHide: Boolean) { + if (toHide) { + binding.localViewLayout.visibility = View.GONE + binding.ibSwapCamera.visibility = View.GONE + } else { + binding.localViewLayout.visibility = View.VISIBLE + binding.ibSwapCamera.visibility = View.VISIBLE + binding.localView.setZOrderOnTop(true) + } + } + + private fun screenShareViewRemoteState(toHide: Boolean, needResize: Boolean = true) { + Log.d(TAG, "screenShareViewRemoteState toHide: $toHide") + if (toHide) { + binding.screenShareView.visibility = View.GONE + webexViewModel.isRemoteScreenShareON = false + } else { + binding.screenShareView.visibility = View.VISIBLE + webexViewModel.isRemoteScreenShareON = true + } + if (needResize) { + resizeRemoteVideoView() + } + } + + private fun resizeRemoteVideoView() { + Log.d(TAG, "resizeRemoteVideoView isRemoteScreenShareON ${webexViewModel.isRemoteScreenShareON}") + if (webexViewModel.isRemoteScreenShareON) { + val width = resources.getDimension(R.dimen.remote_video_view_width).toInt() + val height = resources.getDimension(R.dimen.remote_video_view_height).toInt() + + val params = ConstraintLayout.LayoutParams(width, height) + params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + params.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID + params.marginStart = resources.getDimension(R.dimen.remote_video_view_margin_start).toInt() + params.bottomMargin = resources.getDimension(R.dimen.remote_video_view_margin_Bottom).toInt() + binding.remoteViewLayout.layoutParams = params + binding.remoteViewLayout.background = ContextCompat.getDrawable(requireActivity(), R.drawable.surfaceview_border) + binding.remoteView.setZOrderOnTop(true) + } else { + val params = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT) + params.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID + params.rightToRight = ConstraintLayout.LayoutParams.PARENT_ID + params.topToTop = ConstraintLayout.LayoutParams.PARENT_ID + binding.remoteViewLayout.layoutParams = params + binding.remoteViewLayout.background = ContextCompat.getDrawable(requireActivity(), R.drawable.surfaceview_transparent_border) + binding.remoteView.setZOrderOnTop(false) + } + } + + private fun videoViewState(toHide: Boolean) { + localVideoViewState(toHide) + if (toHide) { + binding.remoteViewLayout.visibility = View.GONE + } else { + binding.remoteViewLayout.visibility = View.VISIBLE + } + + videoViewTextColorState(toHide) + videoButtonState(toHide) + } + + private fun videoButtonState(videoViewHidden: Boolean) { + if (videoViewHidden) { + binding.ibVideo.background = ContextCompat.getDrawable(requireActivity(), R.drawable.turn_off_video_active) + } else { + binding.ibVideo.background = ContextCompat.getDrawable(requireActivity(), R.drawable.turn_on_video_default) + } + } + + private fun endIncomingCall() { + webexViewModel.currentCallId?.let { + endIncomingCall(it) + } ?: run { + activity?.finish() + } + } + + private fun endIncomingCall(callId: String) { + if (webexViewModel.incomingCallJoinedCallId != null && webexViewModel.incomingCallJoinedCallId == callId) + webexViewModel.hangup(callId) + else + webexViewModel.rejectCall(callId) + } + + private fun onCallConnected(callId: String) { + Log.d(TAG, "CallControlsFragment onCallConnected callerId: $callId, currentCallId: ${webexViewModel.currentCallId}") + + Handler(Looper.getMainLooper()).post { + + val layout = webexViewModel.getCompositedLayout() + Log.d(TAG, "onCallConnected getCompositedLayout: $layout") + webexViewModel.setCompositedLayout(layout) + webexViewModel.setRemoteVideoRenderMode(callId, webexViewModel.scalingMode) + + webexViewModel.getCall(callId)?.setMultiStreamObserver(object : MultiStreamObserver { + override fun onAuxStreamChanged(event: MultiStreamObserver.AuxStreamChangedEvent?) { + Log.d(tag, "MultiStreamObserver onAuxStreamChanged : $event") + Handler(Looper.getMainLooper()).post { + val auxStream: AuxStream? = event?.getAuxStream() + + when (event) { + is MultiStreamObserver.AuxStreamOpenedEvent -> { + if (event.isSuccessful()) { + val auxStreamViewHolder = mAuxStreamViewMap[event.getRenderView()] + Log.d(tag, "MultiStreamObserver AuxStreamOpenedEvent successful") + auxStreamViewHolder?.let { + binding.viewAuxVideos.addView(it.item) + val membership = auxStream?.getPerson() + Log.d(tag, "MultiStreamObserver AuxStreamOpenedEvent successful membership: " + membership?.getDisplayName()) + it.textView.text = membership?.getDisplayName() + } + } else { + Log.d(tag, "MultiStreamObserver AuxStreamOpenedEvent failed: " + event.getError()?.errorMessage) + mAuxStreamViewMap.remove(event.getRenderView()) + } + } + is MultiStreamObserver.AuxStreamClosedEvent -> { + if (event.isSuccessful()) { + Log.d(tag, "MultiStreamObserver AuxStreamClosedEvent successful") + val auxStreamViewHolder = mAuxStreamViewMap[event.getRenderView()] + mAuxStreamViewMap.remove(event.getRenderView()) + binding.viewAuxVideos.removeView(auxStreamViewHolder?.item) + } else { + Log.d(tag, "MultiStreamObserver AuxStreamClosedEvent failed: " + event.getError()?.errorMessage) + } + } + is MultiStreamObserver.AuxStreamSendingVideoEvent -> { + Log.d(tag, "AuxStreamSendingVideoEvent: " + auxStream?.isSendingVideo()) + auxStream?.let { + val auxStreamViewHolder = mAuxStreamViewMap[it.getRenderView()] + + if (auxStreamViewHolder != null) { + if (it.isSendingVideo()) { + auxStreamViewHolder.viewAvatar.visibility = View.GONE + } else { + val membership = it.getPerson() + membership?.let { member -> + if (member.getPersonId().isNotEmpty()) { + auxStreamViewHolder.viewAvatar.visibility = View.VISIBLE + } + } + } + } + } + } + is MultiStreamObserver.AuxStreamPersonChangedEvent -> { + Log.d(tag, "MultiStreamObserver AuxStreamPersonChangedEvent getPerson: " + auxStream?.getPerson() + " from: " + event.from() + " to: " + event.to()) + auxStream?.let { + val auxStreamViewHolder = mAuxStreamViewMap[it.getRenderView()] + val membership = it.getPerson() + membership?.let { member -> + Log.d(tag, "MultiStreamObserver AuxStreamPersonChangedEvent name: " + member.getDisplayName()) + auxStreamViewHolder?.viewAvatar?.visibility = if (it.isSendingVideo()) View.GONE else View.VISIBLE + auxStreamViewHolder?.textView?.text = member.getDisplayName() + } + } + } + is MultiStreamObserver.AuxStreamSizeChangedEvent -> { + Log.d(tag, "MultiStreamObserver AuxStreamSizeChangedEvent width: " + event.getAuxStream()?.getSize()?.width + + " height: " + event.getAuxStream()?.getSize()?.height) + } + } + } + } + + override fun onAuxStreamAvailable(): View? { + Log.d(tag, "MultiStreamObserver onAuxStreamAvailable") + val auxStreamView: View = LayoutInflater.from(activity).inflate(R.layout.remote_video_view, null) + val auxStreamViewHolder = AuxStreamViewHolder(auxStreamView) + mAuxStreamViewMap[auxStreamViewHolder.mediaRenderView] = auxStreamViewHolder + return auxStreamViewHolder.mediaRenderView + } + + override fun onAuxStreamUnavailable(): View? { + Log.d(tag, "MultiStreamObserver onAuxStreamUnavailable") + return null + } + + }) + + if (callId == webexViewModel.currentCallId) { + val callInfo = webexViewModel.getCall(callId) + + var isSelfVideoMuted = true + callInfo?.let { _callInfo -> + isSelfVideoMuted = !_callInfo.isSendingVideo() + webexViewModel.isRemoteVideoMuted = !_callInfo.isReceivingVideo() + Log.d(TAG, "CallControlsFragment onCallConnected isAudioOnly: ${_callInfo.isAudioOnly()} isSelfVideoMuted: ${isSelfVideoMuted}, webexViewModel.isRemoteVideoMuted: ${webexViewModel.isRemoteVideoMuted}") + Log.d(TAG, "CallControlsFragment onCallConnected from: ${_callInfo.getFrom()?.getDisplayName()} to: ${_callInfo.getTo()?.getDisplayName()}") + } + + if (isIncomingActivity) { + if (callId == webexViewModel.currentCallId) { + binding.videoCallLayout.visibility = View.VISIBLE + incomingLayoutState(true) + } + } + + webexViewModel.isLocalVideoMuted = isSelfVideoMuted + + if (webexViewModel.isLocalVideoMuted) { + localVideoViewState(true) + videoButtonState(true) + } else { + localVideoViewState(false) + videoButtonState(false) + } + + if (webexViewModel.isRemoteVideoMuted) { + binding.remoteViewLayout.visibility = View.GONE + } else { + binding.remoteViewLayout.visibility = View.VISIBLE + } + + binding.controlGroup.visibility = View.VISIBLE + + screenShareButtonVisibilityState() + videoViewTextColorState(webexViewModel.isRemoteVideoMuted) + + } + + } + } + + private fun onScreenShareStateChanged(callId: String, label: String) { + Log.d(TAG, "CallControlsFragment onScreenShareStateChanged callerId: $callId, label: $label") + + if (webexViewModel.currentCallId != callId) { + return + } + + Handler(Looper.getMainLooper()).post { + + val callInfo = webexViewModel.getCall(callId) + + val remoteSharing = isReceivingSharing(callId) + val localSharing = isLocalSharing(callId) + Log.d(TAG, "CallControlsFragment onScreenShareStateChanged isRemoteSharing: ${remoteSharing}, isLocalSharing: ${localSharing}") + + if (localSharing) { + updateScreenShareButtonState(ShareButtonState.ON) + } else { + updateScreenShareButtonState(ShareButtonState.OFF) + } + } + } + + private fun onScreenShareVideoStreamInUseChanged(callId: String) { + Log.d(TAG, "CallControlsFragment onScreenShareVideoStreamInUseChanged callerId: $callId") + + if (webexViewModel.currentCallId != callId) { + return + } + + Handler(Looper.getMainLooper()).post { + + val remoteSharing = isReceivingSharing(callId) + val localSharing = isLocalSharing(callId) + Log.d(TAG, "CallControlsFragment onScreenShareVideoStreamInUseChanged isRemoteSharing: ${remoteSharing}, isLocalSharing: ${localSharing}") + if (remoteSharing) { + binding.controlGroup.visibility = View.GONE + screenShareViewRemoteState(false) + val view = webexViewModel.getSharingRenderView(callId) + if (view == null) { + webexViewModel.setSharingRenderView(callId, binding.screenShareView) + } + } + else { + onVideoStreamingChanged(callId) + screenShareViewRemoteState(true) + binding.controlGroup.visibility = View.VISIBLE + } + + videoViewTextColorState(!remoteSharing) + } + } + + private fun onVideoStreamingChanged(callId: String) { + Log.d(TAG, "CallControlsFragment onVideoStreamingChanged callerId: $callId") + + if (webexViewModel.currentCallId == null) { + return + } + + Handler(Looper.getMainLooper()).post { + + if (webexViewModel.isLocalVideoMuted) { + localVideoViewState(true) + } else { + localVideoViewState(false) + val pair = webexViewModel.getVideoRenderViews(callId) + if (pair.first == null) { + webexViewModel.setVideoRenderViews(callId, binding.localView, binding.remoteView) + } + } + + if (webexViewModel.isRemoteVideoMuted) { + binding.remoteViewLayout.visibility = View.GONE + } else { + if (webexViewModel.isRemoteScreenShareON) { + resizeRemoteVideoView() + } + binding.remoteViewLayout.visibility = View.VISIBLE + val pair = webexViewModel.getVideoRenderViews(callId) + if (pair.second == null) { + webexViewModel.setVideoRenderViews(callId, binding.localView, binding.remoteView) + } + } + + videoViewTextColorState(webexViewModel.isRemoteVideoMuted) + + Log.d(TAG, "CallControlsFragment onVideoStreamingChanged isLocalVideoMuted: ${webexViewModel.isLocalVideoMuted}, isRemoteVideoMuted: ${webexViewModel.isRemoteVideoMuted}") + + if (webexViewModel.isLocalVideoMuted) { + videoButtonState(true) + } else { + videoButtonState(false) + } + } + } + + private fun toggleSpeaker(v: View) { + v.isSelected = !v.isSelected + when { + v.isSelected -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.SPEAKER) + } + audioManagerUtils?.isBluetoothHeadsetConnected == true -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.BLUETOOTH_HEADSET) + } + audioManagerUtils?.isWiredHeadsetOn == true -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.HEADSET) + } + else -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.PHONE) + } + } + } + + internal fun handleFCMIncomingCall(callId: String) { + Handler(Looper.getMainLooper()).post { + webexViewModel.setFCMIncomingListenerObserver(callId) + onIncomingCall(webexViewModel.getCall(callId)) + } + } + + private fun onIncomingCall(call: Call?) { + Handler(Looper.getMainLooper()).post { + + Log.d(TAG, "CallControlsFragment onIncomingCall callerId: ${call?.getCallId()}, callInfo title: ${call?.getTitle()}") + + binding.incomingCallHeader.visibility = View.GONE + + val schedules= call?.getSchedules() + incomingLayoutState(false) + + schedules?.let { + val item = schedules.first() + if (!checkIncomingAdapterList(item)) { + val model = MeetingInfoModel.convertToMeetingInfoModel(call, item) + incomingInfoAdapter.info.add(model) + Log.d(TAG, "CallControlsFragment onIncomingCall schedules size: ${schedules.size}") + } + } ?: run { + val group = call?.isGroupCall() ?: false + if (group) { + val model = SpaceIncomingCallModel(call) + incomingInfoAdapter.info.add(model) + } else { + val model = OneToOneIncomingCallModel(call) + incomingInfoAdapter.info.add(model) + } + } + + incomingInfoAdapter.notifyDataSetChanged() + } + } + + private fun checkIncomingAdapterList(item: CallSchedule): Boolean { + incomingInfoAdapter.info.forEach { _model -> + if ((_model is MeetingInfoModel) && (_model.meetingId == item.getId())) { + return true + } + } + + return false + } + + private fun onCallJoined(call: Call?) { + Log.d(TAG, "CallControlsFragment onCallJoined callerId: ${call?.getCallId().orEmpty()}, currentCallId: ${webexViewModel.currentCallId}") + Handler(Looper.getMainLooper()).post { + if (call?.getCallId().orEmpty() == webexViewModel.currentCallId) { + showCallHeader(call?.getCallId().orEmpty()) + call?.let { + val schedules = it.getSchedules() + schedules?.let { + binding.callingHeader.text = getString(R.string.meeting) + } + } + } + if (callingActivity == 1) { + webexViewModel.incomingCallJoinedCallId = call?.getCallId().orEmpty() + } + Log.d(TAG,"CallControlsFragment callingHeader text: ${binding.callingHeader.text}") + } + } + + private fun showCallHeader(callId: String) { + Handler(Looper.getMainLooper()).post { + try { + val callInfo = webexViewModel.getCall(callId) + Log.d(TAG, "CallControlsFragment showCallHeader callerId: $callId, callInfo title: ${callInfo?.getTitle()}") + + binding.tvName.text = callInfo?.getTitle() + binding.callingHeader.text = getString(R.string.onCall) + } catch (e: Exception) { + Log.d(TAG, "error: ${e.message}") + } + } + } + + private fun onCallFailed(callId: String) { + Log.d(TAG, "CallControlsFragment onCallFailed callerId: $callId") + + Handler(Looper.getMainLooper()).post { + if (webexViewModel.isAddedCall) { + resumePrevCallIfAdded(callId) + updateCallHeader() + } + + callFailed = !webexViewModel.isAddedCall + + val callActivity = activity as CallActivity? + callActivity?.alertDialog(!webexViewModel.isAddedCall, "") + } + } + + private fun onCallDisconnected(call: Call?) { + call?.let { _call -> + Log.d(TAG, "CallControlsFragment onCallDisconnected callerId: ${_call.getCallId().orEmpty()}") + Handler(Looper.getMainLooper()).post { + val schedules = call.getSchedules() + schedules?.let { + incomingLayoutState(false) + } ?: run { + if (call.isGroupCall()) { + incomingLayoutState(false) + } + } + } + } + } + + private fun onCallTerminated(callId: String) { + Log.d(TAG, "CallControlsFragment onCallTerminated callerId: $callId") + + Handler(Looper.getMainLooper()).post { + if (webexViewModel.isAddedCall) { + resumePrevCallIfAdded(callId) + updateCallHeader() + initAddedCallControls() + } + + CallObjectStorage.removeCallObject(callId) + + if (!callFailed && !webexViewModel.isAddedCall) { + callEndedUIUpdate(callId, true) + } + webexViewModel.isAddedCall = false + } + } + + private fun callEndedUIUpdate(callId: String, terminated: Boolean = false) { + if (isIncomingActivity) { + for (model in incomingInfoAdapter.info) { + if ( (model is OneToOneIncomingCallModel) && (model.call?.getCallId() == callId)) { + incomingInfoAdapter.info.remove(model) + break + } else if (model is MeetingInfoModel) { + if (Date().after(model.endTime)) { + incomingInfoAdapter.info.remove(model) + break + } + + if (terminated && (model.call?.getCallId().orEmpty() == callId)) { + incomingInfoAdapter.info.remove(model) + break + } + } else if ( (model is SpaceIncomingCallModel) && (model.call?.getCallId() == callId)) { + incomingInfoAdapter.info.remove(model) + break + } + } + incomingInfoAdapter.notifyDataSetChanged() + + if (incomingInfoAdapter.info.isNotEmpty()) { + webexViewModel.currentCallId = null + incomingLayoutState(false) + } else { + activity?.finish() + } + } else { + activity?.finish() + } + } + + private fun initAddedCallControls() { + binding.ibTransferCall.visibility = View.INVISIBLE + binding.ibVideo.visibility = View.VISIBLE + + binding.ibAdd.visibility = View.VISIBLE + binding.ibMerge.visibility = View.INVISIBLE + } + + private fun onNewCallHeader(callerId: String?) { + binding.callingHeader.text = getString(R.string.calling) + binding.tvName.text = callerId + } + + private fun resumePrevCallIfAdded(callId: String) { + //resume old call + if (callId == webexViewModel.currentCallId) { + webexViewModel.currentCallId = webexViewModel.oldCallId + Log.d(TAG, "resumePrevCallIfAdded currentCallId = ${webexViewModel.currentCallId}") + webexViewModel.currentCallId?.let { _currentCallId -> + webexViewModel.holdCall(_currentCallId) + } + webexViewModel.oldCallId = null //old is disconnected need to make it null + } + } + + private fun updateCallHeader() { + webexViewModel.currentCallId?.let { + showCallHeader(it) + } + } + + private fun startAssociatedCall(dialNumber: String, associationType: CallAssociationType, audioCall: Boolean) { + Log.d(tag, "startAssociatedCall dialNumber = $dialNumber : associationType = $associationType : audioCall = $audioCall") + webexViewModel.currentCallId?.let { callId -> + onNewCallHeader(callId) + webexViewModel.startAssociatedCall(callId, dialNumber, associationType, audioCall) + } + } + + private fun transferCall() { + Log.d(tag, "transferCall currentCallId = ${webexViewModel.currentCallId}, oldCallId = ${webexViewModel.oldCallId}") + if (webexViewModel.currentCallId != null && webexViewModel.oldCallId != null) { + webexViewModel.transferCall(webexViewModel.oldCallId!!, webexViewModel.currentCallId!!) + } + } + + private fun mergeCalls() { + Log.d(tag, "mergeCalls currentCallId = ${webexViewModel.currentCallId}, targetCallId = ${webexViewModel.oldCallId}") + if (webexViewModel.currentCallId != null && webexViewModel.oldCallId != null) { + webexViewModel.mergeCalls(webexViewModel.currentCallId!!, webexViewModel.oldCallId!!) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) { + val callNumber = data?.getStringExtra(CALLER_ID) ?: "" + //start call association to add new person on call + startAssociatedCall(callNumber, CallAssociationType.Transfer, true) + } + } + + private fun muteSelfVideo(value: Boolean) { + webexViewModel.currentCallId?.let { + webexViewModel.muteSelfVideo(it, value) + } + } + + private fun receivingVideoListener(call: Call?) { + Log.d(TAG, "receivingVideoListener") + call?.let { + if (it.isReceivingVideo()) { + webexViewModel.setReceivingVideo(it, false) + } else { + webexViewModel.setReceivingVideo(it, true) + } + } + } + + private fun receivingAudioListener(call: Call?) { + Log.d(TAG, "receivingAudioListener") + call?.let { + if (it.isReceivingAudio()) { + webexViewModel.setReceivingAudio(it, false) + } else { + webexViewModel.setReceivingAudio(it, true) + } + } + } + + private fun receivingSharingListener(call: Call?) { + Log.d(TAG, "receivingSharingListener") + call?.let { + if (it.isReceivingSharing()) { + webexViewModel.setReceivingSharing(it, false) + } else { + webexViewModel.setReceivingSharing(it, true) + } + } + } + + private fun compositeStreamLayoutClickListener(call: Call?) { + Log.d(TAG, "compositeStreamLayoutClickListener getCompositedLayout: ${webexViewModel.getCompositedLayout()}") + + if (webexViewModel.compositedVideoLayout == MediaOption.CompositedVideoLayout.NOT_SUPPORTED) { + showDialogWithMessage(requireContext(), R.string.composite_stream, resources.getString(R.string.composite_stream_not_supported)) + return + } + + var layout = webexViewModel.compositedVideoLayout + + when (layout) { + MediaOption.CompositedVideoLayout.FILMSTRIP -> { + layout = MediaOption.CompositedVideoLayout.GRID + } + MediaOption.CompositedVideoLayout.GRID -> { + layout = MediaOption.CompositedVideoLayout.SINGLE + } + MediaOption.CompositedVideoLayout.SINGLE -> { + layout = MediaOption.CompositedVideoLayout.FILMSTRIP + } + else -> {} + } + + webexViewModel.setCompositedLayout(layout) + } + + private fun scalingModeClickListener(call: Call?) { + Log.d(TAG, "scalingModeClickListener") + + when (webexViewModel.scalingMode) { + Call.VideoRenderMode.Fit -> { + webexViewModel.scalingMode = Call.VideoRenderMode.CropFill + } + Call.VideoRenderMode.CropFill -> { + webexViewModel.scalingMode = Call.VideoRenderMode.StretchFill + } + Call.VideoRenderMode.StretchFill -> { + webexViewModel.scalingMode = Call.VideoRenderMode.Fit + } + } + + webexViewModel.setRemoteVideoRenderMode(call?.getCallId().orEmpty(), webexViewModel.scalingMode) + } + + private fun showBottomSheet(call: Call?) { + callOptionsBottomSheetFragment.call = call + callOptionsBottomSheetFragment.scalingModeValue = webexViewModel.scalingMode + callOptionsBottomSheetFragment.compositeLayoutValue = webexViewModel.compositedVideoLayout + callOptionsBottomSheetFragment.streamMode = webexViewModel.streamMode + activity?.supportFragmentManager?.let { callOptionsBottomSheetFragment.show(it, CallBottomSheetFragment.TAG) } + } + + class IncomingInfoAdapter(private val incomingCallEvent: (Call?) -> Unit, private val IncomingCallPickEvent: (Call?) -> Unit, private val incomingCallCancelEvent: (Call?) -> Unit) : RecyclerView.Adapter() { + var info: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): IncomingInfoViewHolder { + return IncomingInfoViewHolder(ListItemCallMeetingBinding.inflate(LayoutInflater.from(parent.context), parent, false), incomingCallEvent, IncomingCallPickEvent, incomingCallCancelEvent) + } + + override fun getItemCount(): Int = info.size + + override fun onBindViewHolder(holder: IncomingInfoViewHolder, position: Int) { + holder.bind(info[position]) + } + } + + class IncomingInfoViewHolder(private val binding: ListItemCallMeetingBinding, private val incomingCallEvent: (Call?) -> Unit, + private val IncomingCallPickEvent: (Call?) -> Unit, private val incomingCallCancelEvent: (Call?) -> Unit) : RecyclerView.ViewHolder(binding.root) { + var item: IncomingCallInfoModel? = null + val tag = "IncomingInfoViewHolder" + init { + binding.meetingJoinButton.setOnClickListener { + item?.let { model -> + if (model is MeetingInfoModel) { + incomingCallEvent(model.call) + Log.d(tag, "JoinButton clicked meetingInfo: ${model.subject}") + IncomingCallPickEvent(model.call) + model.isEnabled = false + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + else if (model is SpaceIncomingCallModel) { + incomingCallEvent(model.call) + Log.d(tag, "JoinButton clicked SpaceCall") + IncomingCallPickEvent(model.call) + model.isEnabled = false + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + } + } + + binding.ivPickCall.setOnClickListener { + item?.let { model -> + if (model is OneToOneIncomingCallModel) { + incomingCallEvent(model.call) + Log.d(tag, "ivPickCall clicked") + IncomingCallPickEvent(model.call) + model.isEnabled = false + binding.ivPickCall.alpha = 0.5f + binding.ivPickCall.isEnabled = false + } + } + } + + binding.ivCancelCall.setOnClickListener { + item?.let { model -> + if (model is OneToOneIncomingCallModel) { + incomingCallCancelEvent(model.call) + } + } + } + } + + fun bind(model: IncomingCallInfoModel) { + item = model + + if (model is MeetingInfoModel) { + if (model.isEnabled) { + binding.meetingJoinButton.alpha = 1.0f + binding.meetingJoinButton.isEnabled = true + } else { + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + + binding.titleTextView.text = model.subject + binding.meetingTimeTextView.text = model.timeString + binding.meetingTimeTextView.visibility = View.VISIBLE + binding.callingOneToOneButtonLayout.visibility = View.GONE + binding.meetingJoinButton.visibility = View.VISIBLE + } else if (model is OneToOneIncomingCallModel) { + if (model.isEnabled) { + binding.ivPickCall.alpha = 1.0f + binding.ivPickCall.isEnabled = true + } else { + binding.ivPickCall.alpha = 0.5f + binding.ivPickCall.isEnabled = false + } + + binding.meetingJoinButton.visibility = View.GONE + binding.meetingTimeTextView.visibility = View.GONE + binding.callingOneToOneButtonLayout.visibility = View.VISIBLE + binding.titleTextView.text = model.call?.getTitle() + } else if (model is SpaceIncomingCallModel) { + if (model.isEnabled) { + binding.meetingJoinButton.alpha = 1.0f + binding.meetingJoinButton.isEnabled = true + } else { + binding.meetingJoinButton.alpha = 0.5f + binding.meetingJoinButton.isEnabled = false + } + + binding.meetingTimeTextView.visibility = View.GONE + binding.titleTextView.text = model.call?.getTitle() + binding.callingOneToOneButtonLayout.visibility = View.GONE + binding.meetingJoinButton.visibility = View.VISIBLE + } + binding.executePendingBindings() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt new file mode 100644 index 0000000..96b5666 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val callModule = module { + viewModel { + CallViewModel(get()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt new file mode 100644 index 0000000..67d3a6f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt @@ -0,0 +1,21 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallObserver + +/* +* This interface is written to overcome the limitation of live data postValue. +* When SDK pushes media events continuously then some events were getting lost. +* When post value gets trigger continuously then the latest value replaces the previous one and then the previous value doesn't reach to the UI observer. +* To overcome that limitation, the interface registration happens from UI and the all events now directly reaches to UI without any postValue. +* */ +interface CallObserverInterface { + fun onConnected(call: Call?) {} + fun onRinging(call: Call?) {} + fun onWaiting(call: Call?) {} + fun onDisconnected(call: Call?, event: CallObserver.CallDisconnectedEvent?) {} + fun onInfoChanged(call: Call?) {} + fun onMediaChanged(call: Call?, event: CallObserver.MediaChangedEvent?) {} + fun onCallMembershipChanged(call: Call?, event: CallObserver.CallMembershipChangedEvent?) {} + fun onScheduleChanged(call: Call?) {} +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt new file mode 100644 index 0000000..3b2a4a2 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallViewModel.kt @@ -0,0 +1,6 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import androidx.lifecycle.ViewModel +import com.ciscowebex.androidsdk.Webex + +class CallViewModel(private val webex: Webex) : ViewModel() { } \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt new file mode 100644 index 0000000..a874918 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialFragment.kt @@ -0,0 +1,173 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCallBinding +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.hideKeyboard +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.showKeyboard + +class DialFragment : Fragment() { + + lateinit var binding: FragmentCallBinding + private var isAddingCall = false + + companion object{ + private const val IS_ADDING_CALL = "isAddingCall" + private const val CALLER_ID = "callerId" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return FragmentCallBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + isAddingCall = arguments?.getBoolean(IS_ADDING_CALL) ?: false + } + .root + } + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val dialKeysList = listOf( + binding.tvNumber1, + binding.tvNumber2, + binding.tvNumber3, + binding.tvNumber4, + binding.tvNumber5, + binding.tvNumber6, + binding.tvNumber7, + binding.tvNumber8, + binding.tvNumber9, + binding.tvNumberStar, + binding.tvNumberHash + ) + for (dialKey in dialKeysList) { + dialKey.setOnClickListener { updateDialText(it) } + } + + binding.ibStartCall.setOnClickListener { + val dialText = binding.etDialInput.text.toString() + if(isAddingCall){ + val intent = Intent() + intent.putExtra(CALLER_ID, dialText) + activity?.setResult(Activity.RESULT_OK, intent) + activity?.finish() + }else{ + startActivity(context?.let { ctx -> CallActivity.getOutgoingIntent(ctx, dialText) }) + } + } + + binding.ibKeypadToggle.setOnClickListener { + binding.dialButtonsContainer.visibility = View.GONE + enableInput() + binding.toggleButtonsContainer.showNext() + } + + binding.ibBackspace.setOnClickListener { + var str = binding.etDialInput.text.toString() + if (str.isNotEmpty()) { + str = str.substring(0, str.length - 1) + binding.etDialInput.setText(str) + binding.etDialInput.setSelection(binding.etDialInput.text.length) + } + } + + binding.ibBackspace.setOnLongClickListener { + binding.etDialInput.setText("") + true + } + + binding.llNumber0.setOnLongClickListener { + binding.etDialInput.append(getString(R.string.number_plus)) + true + } + + binding.llNumber0.setOnClickListener { + binding.etDialInput.setText(binding.etDialInput.text.toString() + "0") + } + + disableInput() + + binding.ibNumpadToggle.setOnClickListener { + disableInput() + binding.dialButtonsContainer.visibility = View.VISIBLE + binding.toggleButtonsContainer.showNext() + } + } + + private fun enableInput() { + binding.etDialInput.inputType = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + binding.etDialInput.setSelection(binding.etDialInput.text.length) + binding.etDialInput.showKeyboard() + binding.etDialInput.requestFocus() + } + + private fun disableInput() { + binding.etDialInput.inputType = InputType.TYPE_NULL + context?.hideKeyboard(binding.etDialInput) + } + + override fun onResume() { + super.onResume() + if (binding.ibNumpadToggle.visibility == View.VISIBLE) { + binding.etDialInput.showKeyboard() + } + } + + override fun onPause() { + super.onPause() + context?.hideKeyboard(binding.etDialInput) + } + + @SuppressLint("SetTextI18n") + private fun updateDialText(view: View?) { + val editText = binding.etDialInput + when (view?.id) { + R.id.tv_number_1 -> { + editText.setText(editText.text.toString() + "1") + } + R.id.tv_number_2 -> { + editText.setText(editText.text.toString() + "2") + } + R.id.tv_number_3 -> { + editText.setText(editText.text.toString() + "3") + } + R.id.tv_number_4 -> { + editText.setText(editText.text.toString() + "4") + } + R.id.tv_number_5 -> { + editText.setText(editText.text.toString() + "5") + } + R.id.tv_number_6 -> { + editText.setText(editText.text.toString() + "6") + } + R.id.tv_number_7 -> { + editText.setText(editText.text.toString() + "7") + } + R.id.tv_number_8 -> { + editText.setText(editText.text.toString() + "8") + } + R.id.tv_number_9 -> { + editText.setText(editText.text.toString() + "9") + } + R.id.tv_number_star -> { + editText.setText(editText.text.toString() + "*") + } + R.id.tv_number_hash -> { + editText.setText(editText.text.toString() + "#") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt new file mode 100644 index 0000000..ee6ca31 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/DialerActivity.kt @@ -0,0 +1,56 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityDialerBinding + +class DialerActivity : AppCompatActivity(){ + lateinit var binding: ActivityDialerBinding + + companion object{ + const val IS_ADDING_CALL = "isAddingCall" + fun getIntent(context: Context): Intent{ + return Intent(context, DialerActivity::class.java) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_dialer).also { + binding = it + }.apply { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + setDialerFragment() + handleNavigationClickListener() + } + + } + + private fun handleNavigationClickListener() { + binding.toolbar.setNavigationOnClickListener { onBackPressed() } + } + + private fun setDialerFragment() { + val dialFragment = DialFragment() + val bundle = Bundle() + bundle.putBoolean(IS_ADDING_CALL, true) + dialFragment.arguments = bundle + + val transaction = supportFragmentManager.beginTransaction() + transaction.replace(R.id.container, dialFragment) + transaction.commit() + } + + override fun onBackPressed() { + setResult(Activity.RESULT_CANCELED) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt new file mode 100644 index 0000000..5d8beb6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/IncomingCallInfoModel.kt @@ -0,0 +1,7 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call + +abstract class IncomingCallInfoModel(var call: Call?) { + var isEnabled: Boolean = true +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt new file mode 100644 index 0000000..8d740d6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/MeetingInfoModel.kt @@ -0,0 +1,38 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallSchedule +import java.text.SimpleDateFormat +import java.util.Date + +data class MeetingInfoModel(val _call: Call, val meetingId: String, val startTime: Date, val endTime: Date, val link: String, val subject: String): IncomingCallInfoModel(_call) { + val startTimeString: String = SimpleDateFormat("hh:mm a").format(startTime) + val endTimeString: String = SimpleDateFormat("hh:mm a").format(endTime) + val timeString: String = "$startTimeString - $endTimeString" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MeetingInfoModel + + return meetingId == other.meetingId + } + + override fun hashCode(): Int { + var result = meetingId.hashCode() + result = 31 * result + startTime.hashCode() + result = 31 * result + endTime.hashCode() + result = 31 * result + link.hashCode() + result = 31 * result + subject.hashCode() + return result + } + + companion object { + fun convertToMeetingInfoModel(call: Call, schedule: CallSchedule): MeetingInfoModel { + return MeetingInfoModel(call, schedule.getId().orEmpty(), schedule.getStart() ?: Date(), schedule.getEnd() ?: Date(), + schedule.getMeetingLink().orEmpty(), schedule.getSubject().orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt new file mode 100644 index 0000000..c9e5c53 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/OneToOneIncomingCallModel.kt @@ -0,0 +1,5 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call + +data class OneToOneIncomingCallModel(val _call: Call?): IncomingCallInfoModel(_call) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt new file mode 100644 index 0000000..176523f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/RingerManager.kt @@ -0,0 +1,254 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.SoundPool +import android.net.Uri +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.util.Log +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import com.ciscowebex.androidsdk.kitchensink.R +import org.koin.core.KoinComponent +import java.io.IOException + + +open class RingerManager(private val androidContext: Context): KoinComponent { + enum class RingerType { + Incoming, + Outgoing + } + + private val tag = "RingerManager" + + private var vibrator: Vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + private var audioManager: AudioManager = androidContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val inCallSoundPool: SoundPool = SoundPool.Builder() + .setMaxStreams(1) + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION).build()) + .build() + private var incomingCallPlayer: MediaPlayer? = null + + private var currentPlayingToneId = 0 + private var audioFocusGainForRingtone = false + private var needAudioFocusForCall = false + private val ringerLock = Any() + private var outGoingCallId = 0 + + init { + loadInCallTones() + } + + private fun loadInCallTones() { + outGoingCallId = inCallSoundPool.load(androidContext, R.raw.ring_back, 1) + } + + private fun playOutgoingCallTone() { + if (currentPlayingToneId != 0) return + val streamVolume = getStreamVolume(AudioManager.STREAM_VOICE_CALL) + currentPlayingToneId = inCallSoundPool.play(outGoingCallId, streamVolume, streamVolume, 1, -1, 1.toFloat()) + Log.d(tag, "currentPlayingToneId=$currentPlayingToneId") + } + + fun startRinger(type: RingerType) { + Log.d(tag, "startRinger type: $type") + synchronized(ringerLock) { + handleStartRinger(type) + } + } + + fun stopRinger(type: RingerType) { + Log.d(tag, "stopRinger type: $type") + synchronized(ringerLock) { + handleStopRinger(type) + } + } + + private fun handleStartRinger(type: RingerType) { + Log.d(tag, "handleStartRinger type: $type") + when (type) { + RingerType.Incoming -> playIncomingTone() + RingerType.Outgoing -> playOutgoingCallTone() + } + } + + private fun handleStopRinger(type: RingerType) { + Log.d(tag, "handleStopRinger type: $type") + when (type) { + RingerType.Incoming -> stopIncomingTone() + RingerType.Outgoing -> stopCallTone() + } + } + + private fun playIncomingTone() { + Log.d(tag,"playIncomingTone") + playIncomingCallTone() + } + + private fun stopIncomingTone() { + Log.d(tag, "stopIncomingTone") + stopIncomingCallTone() + } + + private fun playIncomingCallTone() { + Log.d(tag, "playing ringtone for incoming call") + if (incomingCallPlayer?.isPlaying == true) { + Log.d(tag, "incoming call is already playing, ignore this request") + return + } + startVibrate() + Log.d(tag, "start playing incoming tone") + requestAudioFocusForRingtone() + if (incomingCallPlayer == null) { + incomingCallPlayer = MediaPlayer() + setupMediaPlayer(incomingCallPlayer) + } + incomingCallPlayer?.start() + } + + private fun setupMediaPlayer(mediaPlayer: MediaPlayer?) { + Log.d(tag, "setupMediaPlayer") + val incomingCallToneUri: Uri = Uri.parse("android.resource://" + androidContext.packageName + "/" + R.raw.notification_oneone_call) + mediaPlayer?.run { + try { + setDataSource(androidContext, incomingCallToneUri) + setRingtoneStreamType(this) + isLooping = true + prepare() + } catch (e: IOException) { + Log.e(tag, "io exception when setting tone: $incomingCallToneUri") + } catch (illegalException: IllegalStateException) { + Log.e(tag, "Illegal state when setting tone:$incomingCallToneUri") + } + } + } + + private fun stopIncomingCallTone() { + abandonAudioFocusForRingtone() + incomingCallPlayer?.run { + stop() + release() + } + incomingCallPlayer = null + stopVibrate() + } + + private val focusRequest: AudioFocusRequestCompat by lazy { + AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT) + .setOnAudioFocusChangeListener(initFocusChangeListener()) + .build() + } + + private fun initFocusChangeListener(): AudioManager.OnAudioFocusChangeListener { + return AudioManager.OnAudioFocusChangeListener { + when (it) { + AudioManager.AUDIOFOCUS_GAIN -> { + Log.d(tag, "OnAudioFocusChanged:AUDIOFOCUS_GAIN") + } + AudioManager.AUDIOFOCUS_LOSS -> { + Log.d(tag, "OnAudioFocusChanged:AUDIOFOCUS_LOSS") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Log.d(tag, "OnAudioFocusChanged:AUDIOFOCUS_LOSS_TRANSIENT") + } + else -> { + Log.d(tag, "OnAudioFocusChanged, state: $it") + } + } + } + } + + private fun requestAudioFocusForRingtone() { + Log.d(tag, "audioFocusGainForRingtone: $audioFocusGainForRingtone") + if (!audioFocusGainForRingtone) { + audioManager.mode = AudioManager.MODE_RINGTONE + AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) + audioFocusGainForRingtone = true + } + } + + private fun requestAudioFocusForCall() { + if (!audioFocusGainForRingtone) { + Log.d(tag, "requesting audio focus for calls") + val result = AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + audioFocusGainForRingtone = true + } + needAudioFocusForCall = !audioFocusGainForRingtone + } else { + needAudioFocusForCall = true + } + } + + private fun abandonAudioFocusForRingtone() { + Log.d(tag,"audioFocusGainForRingtone: $audioFocusGainForRingtone") + if (audioFocusGainForRingtone) { + AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest) + if (needAudioFocusForCall) { + // in case stop ringer callback is called later than requestAudioFocusForCall(), + // causing audio focus is not gained by call, need to request audio focus again for call + Log.d(tag, "request audio focus again for call") + requestAudioFocusForCall() + } else { + // do not need reset audio mode when in a call + Log.d(tag, "reset audio mode when ringer stopped") + audioManager.mode = AudioManager.MODE_NORMAL + } + audioFocusGainForRingtone = false + } + } + + private fun startVibrate() { + if (shouldVibrate()) { + Log.d(tag, "start vibrating...") + val vibratePattern = longArrayOf(0, 1000, 1750) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createWaveform(vibratePattern, 0) + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + vibrator.vibrate(effect, attributes) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(vibratePattern, 0) + } + } + } + + private fun stopVibrate() { + if (vibrator.hasVibrator()) { + Log.d(tag,"stop vibrating...") + vibrator.cancel() + } + } + + private fun shouldVibrate(): Boolean { + val silentMode = audioManager.ringerMode == AudioManager.RINGER_MODE_SILENT + return vibrator.hasVibrator() && !silentMode + } + + // Only for incoming call + private fun setRingtoneStreamType(mediaPlayer: MediaPlayer?) { + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + mediaPlayer?.setAudioAttributes(attributes) + } + + + private fun stopCallTone() { + Log.d(tag, "stopCallTone currentPlayingToneId=$currentPlayingToneId") + if (currentPlayingToneId != 0) { + inCallSoundPool.stop(currentPlayingToneId) + currentPlayingToneId = 0 + } + } + + private fun getStreamVolume(stream: Int): Float { + return audioManager.getStreamVolume(stream).toFloat() / audioManager.getStreamMaxVolume(stream).toFloat() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt new file mode 100644 index 0000000..42965fb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SpaceIncomingCallModel.kt @@ -0,0 +1,5 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import com.ciscowebex.androidsdk.phone.Call + +data class SpaceIncomingCallModel(val _call: Call?): IncomingCallInfoModel(_call) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt new file mode 100644 index 0000000..a1c5238 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsAdapter.kt @@ -0,0 +1,91 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.participants + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ParticipantsHeaderItemBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ParticipantsListItemBinding +import com.ciscowebex.androidsdk.phone.CallMembership + +class ParticipantsAdapter(private val participants: ArrayList, private val itemClickListener: OnItemActionListener, private val selfId: String) : RecyclerView.Adapter() { + private val viewTypeHeader = 0 + private val viewTypeParticipant = 1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when(viewType) { + viewTypeHeader -> { + HeaderViewHolder(ParticipantsHeaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + viewTypeParticipant -> { + ParticipantViewHolder(ParticipantsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + else -> { + ParticipantViewHolder(ParticipantsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + } + } + + override fun getItemViewType(position: Int): Int { + return if (participants[position] is String) { + viewTypeHeader + } else viewTypeParticipant + } + + override fun getItemCount(): Int { + return participants.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if(participants[position] is String) { + (holder as HeaderViewHolder).bind() + } else { + (holder as ParticipantViewHolder).bind() + } + } + + fun refreshData(list: List) { + participants.clear() + participants.addAll(list) + } + + inner class ParticipantViewHolder(private val binding: ParticipantsListItemBinding): RecyclerView.ViewHolder(binding.root){ + + fun bind(){ + val participant = participants[adapterPosition] as CallMembership + binding.tvName.text = participant.getDisplayName() + binding.imgMute.setImageResource(R.drawable.ic_mic_off_24) + binding.imgMute.visibility = if(!participant.isSendingAudio()) View.VISIBLE else View.INVISIBLE + + val personId = participant.getPersonId() + + if (personId == selfId) { + binding.infoLabelView.visibility = View.VISIBLE + } + else { + binding.infoLabelView.visibility = View.GONE + } + binding.root.setOnClickListener { itemClickListener.onParticipantMuted(personId)} + binding.root.setOnLongClickListener { + itemClickListener.onLetInClicked(participant) + true + } + + } + } + + inner class HeaderViewHolder(private val binding: ParticipantsHeaderItemBinding): RecyclerView.ViewHolder(binding.root){ + + fun bind(){ + binding.tvName.text = participants[adapterPosition] as String + binding.root.setOnClickListener(null) + } + } + + interface OnItemActionListener{ + fun onParticipantMuted(participantId: String) + fun onLetInClicked(callMembership: CallMembership) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt new file mode 100644 index 0000000..6030eca --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/participants/ParticipantsFragment.kt @@ -0,0 +1,146 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.participants + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexViewModel +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentParticipantsBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.phone.CallMembership +import kotlinx.android.synthetic.main.fragment_participants.* + + +class ParticipantsFragment : DialogFragment(), ParticipantsAdapter.OnItemActionListener { + + lateinit var binding: FragmentParticipantsBinding + lateinit var adapter: ParticipantsAdapter + private lateinit var webexViewModel: WebexViewModel + private var currentCallId: String? = null + private var selfId: String? = null + + companion object { + private const val CALL_KEY = "call_id" + private const val SELF_ID_KEY = "self_id" + + fun newInstance(callid: String): ParticipantsFragment { + val bundle = Bundle() + bundle.putString(CALL_KEY, callid) + val fragment = ParticipantsFragment() + fragment.arguments = bundle + return fragment + } + } + + override fun onStart() { + super.onStart() + val dialog: Dialog? = dialog + if (dialog != null) { + val width = ViewGroup.LayoutParams.MATCH_PARENT + val height = ViewGroup.LayoutParams.MATCH_PARENT + dialog.window?.setLayout(width, height) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val participantsBinding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.fragment_participants, container,false).also { binding = it }.apply { + webexViewModel = (activity as? CallActivity)?.webexViewModel!! + Log.d(tag, "onCreateView webexViewModel: $webexViewModel") + selfId = webexViewModel.selfPersonId + setUpViews() + } + return participantsBinding.root + } + + private fun setUpViews() { + adapter = ParticipantsAdapter(arrayListOf(), this, selfId.orEmpty()) + binding.participants.adapter = adapter + + val dividerItemDecoration = DividerItemDecoration(requireContext(), + LinearLayoutManager.VERTICAL) + binding.participants.addItemDecoration(dividerItemDecoration) + + webexViewModel.currentCallId?.let { _callId -> + currentCallId = _callId + webexViewModel.getParticipants(_callId) + } + + webexViewModel.callMembershipsLiveData.observe(this, Observer { + it?.let { callMemberships -> + Log.d(tag, callMemberships.toString()) + val data = arrayListOf() + val stateWiseMap = callMemberships.groupBy { it.getState() } + stateWiseMap.keys.forEach { state -> + val memberships = stateWiseMap[state] + data.add(webexViewModel.getHeader(state)) + data.addAll(memberships.orEmpty()) + } + adapter.refreshData(data) + adapter.notifyDataSetChanged() + } + }) + + webexViewModel.muteAllLiveData.observe(this, Observer { shouldMuteAll -> + if (shouldMuteAll != null) { + tvMute.text = if(shouldMuteAll) getString(R.string.mute_all) else getString(R.string.unmute_all) + } + }) + + binding.tvMute.text = getString(R.string.mute_all) + binding.tvMute.setOnClickListener { + if (webexViewModel.getCall(webexViewModel.currentCallId.orEmpty())?.isCUCMCall() == false) { + webexViewModel.currentCallId?.let { + webexViewModel.muteAllParticipantAudio(it) + } + } else { + showToast(getString(R.string.mute_feature_is_not_available_for_cucm_calls)) + } + } + + binding.close.setOnClickListener { dismiss() } + + } + + fun showToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + + override fun onParticipantMuted(participantId: String) { + currentCallId?.let { + if (webexViewModel.getCall(webexViewModel.currentCallId.orEmpty())?.isCUCMCall() == false || webexViewModel.selfPersonId == participantId) { + webexViewModel.muteParticipant(it, participantId) + adapter.notifyDataSetChanged() + } else { + showToast(getString(R.string.mute_feature_is_not_available_for_cucm_calls)) + } + } + } + + override fun onLetInClicked(callMembership: CallMembership) { + if (callMembership.getState() == CallMembership.State.WAITING) { + context?.let { ctx -> + showDialogWithMessage(ctx, getString(R.string.message), getString(R.string.let_in_confirmation), + onPositiveButtonClick = { dialog, _ -> + currentCallId?.let { + webexViewModel.letIn(it, callMembership) + } + dialog.dismiss() + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt new file mode 100644 index 0000000..1a2d127 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/cucm/UCLoginActivity.kt @@ -0,0 +1,192 @@ +package com.ciscowebex.androidsdk.kitchensink.cucm + + +import android.app.AlertDialog +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.auth.UCSSOWebViewAuthenticator +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityCucmLoginBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogUcloginNonssoBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogUcloginSettingsBinding +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus + + +class UCLoginActivity : BaseActivity() { + lateinit var binding: ActivityCucmLoginBinding + + private var nonSSOAlertDialog: AlertDialog? = null + private var ucSettingsAlertDialog: AlertDialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "UCLoginActivity" + DataBindingUtil.setContentView(this, R.layout.activity_cucm_login) + .also { binding = it } + .apply { + webexViewModel.cucmLiveData.observe(this@UCLoginActivity, Observer { + if (it != null) { + when (WebexRepository.CucmEvent.valueOf(it.first.name)) { + WebexRepository.CucmEvent.ShowSSOLogin -> { + + progressBar.visibility = View.GONE + ssologinWebview.visibility = View.VISIBLE + + nonSSOAlertDialog?.dismiss() + ucSettingsAlertDialog?.dismiss() + + UCSSOWebViewAuthenticator.launchWebView(ssologinWebview, it.second, CompletionHandler { result -> + if (result.isSuccessful) { + Log.d(tag, "UCLoginActivity SSO login Successful") + + Handler(Looper.getMainLooper()).post { + ssologinWebview.visibility = View.GONE + progressBar.visibility = View.VISIBLE + } + } else { + Log.d(tag, "UCLoginActivity SSO login Failed") + ucLoginEvent(getString(R.string.uc_login_failed)) + } + }) + } + WebexRepository.CucmEvent.ShowNonSSOLogin -> { + showUCNonSSOLoginDialog() + } + WebexRepository.CucmEvent.OnUCLoggedIn -> { + ucLoginEvent(getString(R.string.uc_login_success)) + } + WebexRepository.CucmEvent.OnUCLoginFailed -> { + ucLoginEvent(getString(R.string.uc_login_failed)) + } + WebexRepository.CucmEvent.OnUCServerConnectionStateChanged -> { + processServerConnectionStatus(webexViewModel.getUCServerConnectionStatus()) + } + } + } + }) + + progressBar.visibility = View.VISIBLE + + Handler(Looper.getMainLooper()).post { + if (webexViewModel.isUCLoggedIn()) { + ucLoginEvent(getString(R.string.uc_login_success)) + } else { + showUCLoginSettingsDialog() + } + } + } + } + + private fun processServerConnectionStatus(status: UCLoginServerConnectionStatus) { + Log.d(tag, "processServerConnectionStatus status: $status") + when (status) { + UCLoginServerConnectionStatus.Idle -> {} + UCLoginServerConnectionStatus.Connecting -> {} + UCLoginServerConnectionStatus.Connected -> { + ucLoginEvent(getString(R.string.uc_server_connected)) + } + UCLoginServerConnectionStatus.Disconnected -> {} + UCLoginServerConnectionStatus.Failed -> {} + } + } + + private fun ucLoginEvent(message: String) { + Handler(Looper.getMainLooper()).post { + binding.ssologinWebview.visibility = View.GONE + binding.progressBar.visibility = View.GONE + showToast(message) + } + } + + private fun showToast(message: String) { + val toast = Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT) + toast.show() + updateUCData() + } + + private fun setUCDomainServerUrl(domain: String, serverUrl: String) { + Log.d(tag, "setUCDomainServerUrl domain: $domain, serverUrl: $serverUrl") + webexViewModel.setUCDomainServerUrl(ucDomain = domain, serverUrl = serverUrl) + } + + private fun showUCLoginSettingsDialog() { + val builder = AlertDialog.Builder(this) + builder.setTitle(R.string.uc_login_settings) + DialogUcloginSettingsBinding.inflate(layoutInflater).apply { + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + + Handler(Looper.getMainLooper()).postDelayed({ + setUCDomainServerUrl(domain.text.toString(), server.text.toString()) + }, 200) + dialog.dismiss() + } + builder.setNeutralButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.setOnDismissListener { + ucSettingsAlertDialog = null + } + } + ucSettingsAlertDialog = builder.create() + ucSettingsAlertDialog?.setCanceledOnTouchOutside(false) + ucSettingsAlertDialog?.show() + } + + private fun showUCNonSSOLoginDialog() { + val builder = AlertDialog.Builder(this) + builder.setTitle(R.string.uc_login_settings) + DialogUcloginNonssoBinding.inflate(layoutInflater).apply { + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + + Handler(Looper.getMainLooper()).postDelayed({ + val username = username.text.toString() + val password = password.text.toString() + webexViewModel.setCUCMCredential(username, password) + }, 200) + + dialog.dismiss() + } + builder.setNeutralButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.setOnDismissListener { + nonSSOAlertDialog = null + } + } + + nonSSOAlertDialog = builder.create() + nonSSOAlertDialog?.setCanceledOnTouchOutside(false) + nonSSOAlertDialog?.show() + } + + private fun updateUCData() { + Log.d(tag, "updateUCData isCUCMServerLoggedIn: ${webexViewModel.repository.isCUCMServerLoggedIn} ucServerConnectionStatus: ${webexViewModel.repository.ucServerConnectionStatus}") + if (webexViewModel.isCUCMServerLoggedIn) { + binding.ucLoginStatusTextView.visibility = View.VISIBLE + } else { + binding.ucLoginStatusTextView.visibility = View.GONE + } + + when (webexViewModel.ucServerConnectionStatus) { + UCLoginServerConnectionStatus.Connected -> { + binding.ucServerConnectionStatusTextView.text = resources.getString(R.string.phone_service_connected) + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + UCLoginServerConnectionStatus.Failed -> { + val text = resources.getString(R.string.phone_service_failed) + " " + webexViewModel.ucServerConnectionFailureReason + binding.ucServerConnectionStatusTextView.text = text + binding.ucServerConnectionStatusTextView.visibility = View.VISIBLE + } + else -> { + binding.ucServerConnectionStatusTextView.visibility = View.GONE + } + } + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt new file mode 100644 index 0000000..a56b974 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasActivity.kt @@ -0,0 +1,49 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityExtrasBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class ExtrasActivity : AppCompatActivity() { + + lateinit var binding: ActivityExtrasBinding + val tag = "ExtrasActivity" + + private val extrasViewModel: ExtrasViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_extras) + .also { binding = it } + .apply { + btnViewAccessToken.setOnClickListener { + extrasViewModel.getAccessToken() + } + btnRefreshAccessToken.setOnClickListener { + extrasViewModel.getRefreshToken() + } + btnGetJwtTokenExpiry.setOnClickListener { + val expiryDate = extrasViewModel.getJwtAccessTokenExpiration() + val message = expiryDate?.toString()?: getString(R.string.expiry_date_not_available) + showDialogWithMessage(this@ExtrasActivity, R.string.access_token_expiration, message) + } + + setUpObservers() + } + } + + private fun setUpObservers() { + val observer: Observer = Observer { + val accessToken = it?: getString(R.string.no_access_token_yet) + showDialogWithMessage(this, R.string.access_token, accessToken) + } + + extrasViewModel.accessToken.observe(this, observer) + extrasViewModel.refreshToken.observe(this, observer) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt new file mode 100644 index 0000000..c2b9507 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasModule.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val extrasModule = module { + viewModel { ExtrasViewModel(get()) } + + single { ExtrasRepository(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt new file mode 100644 index 0000000..9686ca8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasRepository.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import android.util.Log +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.auth.JWTAuthenticator +import io.reactivex.Observable +import io.reactivex.Single +import java.util.Date + +class ExtrasRepository(private val webex: Webex) { + private val tag = "ExtrasRepository" + fun getAccessToken(): Observable { + return Single.create { emitter -> + webex.authenticator?.getToken(CompletionHandler { result -> + if (result.isSuccessful) { + val token = result.data + emitter.onSuccess(token ?: "No Access Token yet") + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getRefreshToken(): Observable { + return Single.create { emitter -> + if (webex.authenticator is JWTAuthenticator) { + (webex.authenticator as JWTAuthenticator).refreshToken(CompletionHandler { result -> + if (result.isSuccessful) { + val token = result.data + emitter.onSuccess(token ?: "No Access Token yet") + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + } else { + emitter.onError(Throwable("Authenticator should be an instance of JWTAuthenticator")) + } + }.toObservable() + } + + fun getJwtAccessTokenExpiration(): Date? { + Log.d(tag, "isAuthorized : ${webex.authenticator?.isAuthorized()}") + if (webex.authenticator is JWTAuthenticator) { + return (webex.authenticator as JWTAuthenticator).getExpiration() + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt new file mode 100644 index 0000000..8246f25 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/extras/ExtrasViewModel.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink.extras + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers +import java.util.Date + +class ExtrasViewModel(private val extrasRepository: ExtrasRepository) : BaseViewModel() { + private val tag = "ExtrasViewModel" + + private val _accessToken = MutableLiveData() + val accessToken: LiveData = _accessToken + + private val _refreshToken = MutableLiveData() + val refreshToken: LiveData = _refreshToken + + fun getAccessToken() { + extrasRepository.getAccessToken().observeOn(AndroidSchedulers.mainThread()).subscribe({ + _accessToken.postValue(it) + }, { _accessToken.postValue(null) }).autoDispose() + } + + fun getRefreshToken() { + extrasRepository.getRefreshToken().observeOn(AndroidSchedulers.mainThread()).subscribe({ + _refreshToken.postValue(it) + }, { _refreshToken.postValue(null) }).autoDispose() + } + + fun getJwtAccessTokenExpiration(): Date? { + return extrasRepository.getJwtAccessTokenExpiration() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt new file mode 100644 index 0000000..8f7814b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/Data.kt @@ -0,0 +1,15 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import com.google.gson.annotations.SerializedName + +data class Data( + + @SerializedName("id") val id: String?, + @SerializedName("roomId") val roomId: String?, + @SerializedName("callId") val callId: String?, + @SerializedName("state") val state: String?, + @SerializedName("roomType") val roomType: String?, + @SerializedName("personId") val personId: String?, + @SerializedName("personEmail") val personEmail: String?, + @SerializedName("created") val created: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt new file mode 100644 index 0000000..232efd5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/FCMPushModel.kt @@ -0,0 +1,20 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import com.google.gson.annotations.SerializedName + +data class FCMPushModel( + + @SerializedName("id") val id: String?, + @SerializedName("name") val name: String?, + @SerializedName("targetUrl") val targetUrl: String?, + @SerializedName("resource") val resource: String?, + @SerializedName("event") val event: String?, + @SerializedName("orgId") val orgId: String?, + @SerializedName("createdBy") val createdBy: String?, + @SerializedName("appId") val appId: String?, + @SerializedName("ownedBy") val ownedBy: String?, + @SerializedName("status") val status: String?, + @SerializedName("created") val created: String?, + @SerializedName("actorId") val actorId: String?, + @SerializedName("data") val data: Data? +) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt new file mode 100644 index 0000000..3850525 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/KitchenSinkFCMService.kt @@ -0,0 +1,244 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.Html +import android.util.Log +import androidx.core.app.NotificationCompat +import com.ciscowebex.androidsdk.kitchensink.HomeActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.firebase.KitchenSinkFCMService.WebhookResources.CALL_MEMBERSHIPS +import com.ciscowebex.androidsdk.kitchensink.firebase.KitchenSinkFCMService.WebhookResources.MESSAGES +import com.ciscowebex.androidsdk.kitchensink.utils.Base64Utils +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.decryptPushRESTPayload +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.kitchensink.KitchenSinkApp +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import com.ciscowebex.androidsdk.phone.NotificationCallType +import org.json.JSONObject +import org.koin.android.ext.android.inject +import kotlin.random.Random + + +class KitchenSinkFCMService : FirebaseMessagingService() { + + private val repository: WebexRepository by inject() + + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + + Log.d(TAG, "From: " + remoteMessage.from) + Log.d(TAG, "APP isInForeground: " + KitchenSinkApp.inForeground) + if (KitchenSinkApp.inForeground) return + + var notificationData: FCMPushModel? + + if (remoteMessage.data.isNotEmpty()) { + val map = remoteMessage.data + val pushRestPayload = map["body"] + if (!pushRestPayload.isNullOrEmpty()) { + // This FCM notification is generated by PushREST +// sample payload: eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiZGlyIn0..pnhaRj0e109Khb1j.mXZBfDQMj_c4dFaZRwRuVSOI0LcrZRvpnoBknDQDsYKsDVQtIppi1y7cWBsQ8doNLs-Cp6UEkzLlOX_SHOLqYhdHdfo8n5-nRfTI0gUx72UQtvuPGBFKUStU_B7TQmEBs7OQBClHjUNiTIo_Q70NTijE0ErgUzXhpXVHtgDnMW79HDzJ37Y4PUM96ssd8uY7WZuezTKkDYAjVYutQ5-MBe2z3oaFeXqy1hgfWVJY_y2L9eC7RHaMkUFmONaNmiryTssxcp1aWkWOqyMWNlu6igh1.Wy3QMt5_loajfrHrCCyfzQ + Log.d(TAG, "Payload from PushREST : $pushRestPayload") + + // Sample encryption logic + //val dummyPayload = "This is a dummyP@yload! for Testing" + //val encryptedPayload = encryptPushRESTPayload(dummyPayload) + + // Decrypt using key + val decryptedPayload = decryptPushRESTPayload(pushRestPayload) + Log.d(TAG, "Decrypted payload : $decryptedPayload") + val pushRestPayloadJson = getPushRestPayloadModel(decryptedPayload) + buildCallNotification(pushRestPayloadJson) + + } else { + // FCM triggered via webhook from push notification server + val data = map["data"] + data?.let { + val jsonObject = JSONObject(it) + Log.d(TAG, "Message data payload: remoteMessage.data -> $jsonObject") + notificationData = getFCMModel(jsonObject.toString()) + when (notificationData?.resource) { + MESSAGES.value -> { + buildMessageNotification(notificationData) + } + CALL_MEMBERSHIPS.value -> { + //send call notification + notificationData?.let { data -> + buildCallNotification(data) + } + } + else -> { + Log.d(TAG, "Unknown resource found : Resource: ${notificationData?.resource}") + } + } + } + } + } + + } + + private fun buildCallNotification(data: FCMPushModel) { + val callId = Base64Utils.decodeString(data.data?.callId) //locus sessionId returned + Handler(Looper.getMainLooper()).postDelayed({ + val actualCallId = repository.getCallIdByNotificationId(callId, NotificationCallType.Webex) + val callInfo = repository.getCall(actualCallId) + Log.d(TAG, "CallInfo ${callInfo?.getCallId()} title ${callInfo?.getTitle()}") + sendCallNotification(callInfo) + }, 100) + + } + + private fun buildCallNotification(data: PushRestPayloadModel) { + Handler(Looper.getMainLooper()).postDelayed({ + if(data.pushid != null){ + val actualCallId = repository.getCallIdByNotificationId(data.pushid, NotificationCallType.Cucm) + val callInfo = repository.getCall(actualCallId) + Log.d(TAG, "CallInfo ${callInfo?.getCallId()} title ${callInfo?.getTitle()}") + if (data.type == "incomingcall") //data.type = incomingcall,missedcall + sendCallNotification(callInfo, data.displayname) + }else { + Log.d(TAG, "Push id is null") + } + + }, 100) + } + + private fun sendCallNotification(callInfo: Call?, caller: String? = null) { + val callTitle = caller ?: callInfo?.getTitle() + val notificationId = Random.nextInt(10000) + val requestCode = Random.nextInt(10000) + val intent = CallActivity.getIncomingIntent(this) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.putExtra(Constants.Intent.CALL_ID, callInfo?.getCallId()) + intent.action = Constants.Action.WEBEX_CALL_ACTION + + val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, + PendingIntent.FLAG_ONE_SHOT) + val channelId: String = getString(R.string.default_notification_channel_id) + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.app_notification_icon) + .setContentTitle("$callTitle is calling") + .setContentText(getString(R.string.call_description)) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, + WEBEX_CALL_CHANNEL, + NotificationManager.IMPORTANCE_DEFAULT) + notificationManager?.createNotificationChannel(channel) + } + notificationManager?.notify(notificationId, notificationBuilder.build()) + } + + private fun buildMessageNotification(notificationData: FCMPushModel?) { + val roomId = Base64Utils.decodeString(notificationData?.data?.roomId) + repository.listMessages(roomId, CompletionHandler { + Log.d(TAG, "message size: ${it.data?.size}") + val size = it.data?.size ?: 0 + if (size > 0) { + val message = it.data?.get(size - 1) + Log.d(TAG, "last message: ${message?.getTextAsObject()?.getMarkdown()}") + + Log.d(TAG, "Fetching person details") + repository.getPerson(Base64Utils.decodeString(notificationData?.data?.personId), CompletionHandler { personResult -> + Log.d(TAG, "Fetching space details") + repository.getSpace(Base64Utils.decodeString(notificationData?.data?.roomId), CompletionHandler { spaceResult -> + sendNotification(personResult.data?.displayName.orEmpty(), spaceResult.data?.title.orEmpty(), message) + }) + }) + } else { + Log.d(TAG, "message not found") + } + }) + } + + private fun getFCMModel(data: String): FCMPushModel { + return Gson().fromJson(data, FCMPushModel::class.java) + } + + private fun getPushRestPayloadModel(data: String): PushRestPayloadModel { + return Gson().fromJson(data, PushRestPayloadModel::class.java) + } + + /** + * Called if FCM registration token is updated. This may occur if the security of + * the previous token had been compromised. Note that this is called when the + * FCM registration token is initially generated so this is where you would retrieve + * the token. + */ + override fun onNewToken(token: String) { + Log.d(TAG, "Refreshed token: $token") + } + + /** + * Create and show a simple notification containing the received FCM message. + * + */ + private fun sendNotification(personTitle: String, spaceTitle: String, message: Message?) { + val notificationId = Random.nextInt(10000) + val requestCode = Random.nextInt(10000) + val intent = Intent(this, HomeActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.putExtra(Constants.Bundle.MESSAGE_ID, message?.getId().orEmpty()) + intent.action = Constants.Action.MESSAGE_ACTION + + val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, + PendingIntent.FLAG_ONE_SHOT) + val channelId: String = getString(R.string.default_notification_channel_id) + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.app_notification_icon) + .setContentTitle(spaceTitle) + .setContentText(personTitle) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(Html.fromHtml(message?.getTextAsObject()?.getMarkdown().orEmpty(), Html.FROM_HTML_MODE_LEGACY)) + ) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, + MESSAGE_CHANNEL, + NotificationManager.IMPORTANCE_DEFAULT) + notificationManager?.createNotificationChannel(channel) + } + notificationManager?.notify(notificationId, notificationBuilder.build()) + } + + companion object { + private const val TAG = "MyFirebaseMsgService" + private const val WEBEX_CALL_CHANNEL = "WebexCallChannel" + private const val CUCM_CALL_CHANNEL = "CUCMCallChannel" + private const val MESSAGE_CHANNEL = "MessageChannel" + } + + enum class WebhookResources(var value: String) { + MESSAGES("messages"), CALL_MEMBERSHIPS("callMemberships") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt new file mode 100644 index 0000000..7c69bde --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/PushRestPayloadModel.kt @@ -0,0 +1,13 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import com.google.gson.annotations.SerializedName + +data class PushRestPayloadModel ( + @SerializedName("type") val type: String?, + @SerializedName("pushid") val pushid: String?, + @SerializedName("displayname") val displayname: String?, + @SerializedName("displaynumber") val displaynumber: String?, + @SerializedName("payloadversion") val payloadversion: String?, + @SerializedName("huntpilotdn") val huntpilotdn: String?, + @SerializedName("ringexpiretime") val ringexpiretime: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt new file mode 100644 index 0000000..db69fcb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/firebase/RegisterTokenService.kt @@ -0,0 +1,66 @@ +package com.ciscowebex.androidsdk.kitchensink.firebase + +import android.os.AsyncTask +import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +class RegisterTokenService : AsyncTask(){ + val tag = "RegisterTokenService" + private val tokenServiceUrl = "https://serene-meadow-01887.herokuapp.com/register" + + override fun doInBackground(vararg params: String?): String { + var result = "" + try { + result = registerToken(params[0].orEmpty()) + }catch (e: Exception){ + Log.d(tag, "Error in register token", e) + } + return result + } + + override fun onPostExecute(result: String?) { + super.onPostExecute(result) + Log.d(tag, "onPostExecute response $result") + } + + override fun onPreExecute() { + super.onPreExecute() + Log.d(tag, "Sending token to server") + } + + private fun registerToken(jsonInputString: String): String{ + val url = URL(tokenServiceUrl) + + val con: HttpURLConnection = url.openConnection() as HttpURLConnection + con.requestMethod = "POST" + + con.setRequestProperty("Content-Type", "application/json; utf-8") + con.setRequestProperty("Accept", "application/json") + + con.doOutput = true + + Log.d(tag, "request body: $jsonInputString") + con.outputStream.use { os -> + val input = jsonInputString.toByteArray(charset("utf-8")) + os.write(input, 0, input.size) + } + + val code: Int = con.responseCode + Log.d(tag, "response code: $code") + + BufferedReader(InputStreamReader(con.inputStream, "utf-8")).use { br -> + val response = StringBuilder() + var responseLine: String? + while (br.readLine().also { responseLine = it } != null) { + response.append(responseLine!!.trim { it <= ' ' }) + } + println(response.toString()) + Log.d(tag, "response: $response") + return response.toString() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt new file mode 100644 index 0000000..26d55de --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/BaseDialogFragment.kt @@ -0,0 +1,14 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.view.WindowManager +import androidx.fragment.app.DialogFragment + +open class BaseDialogFragment : DialogFragment(){ + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt new file mode 100644 index 0000000..25ef2fd --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingActivity.kt @@ -0,0 +1,69 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMessagingBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsFragment +import com.ciscowebex.androidsdk.kitchensink.person.PeopleFragment +import com.google.android.material.tabs.TabLayoutMediator + +class MessagingActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMessagingBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_messaging) + .also { binding = it } + .apply { + val tabs = listOf(getString(R.string.teams), getString(R.string.spaces), getString(R.string.people), getString(R.string.memberships)) + viewPager.adapter = MessagingPagerAdapter(this@MessagingActivity, tabs.size) + viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + when(position) { + 0 -> messagingMenu.visibility = View.INVISIBLE + 1 -> messagingMenu.visibility = View.VISIBLE + 2 -> messagingMenu.visibility = View.INVISIBLE + 3 -> messagingMenu.visibility = View.INVISIBLE + } + super.onPageSelected(position) + } + }) + + TabLayoutMediator(binding.tabs, binding.viewPager, TabLayoutMediator.TabConfigurationStrategy { tab, position -> + tab.text = tabs[position] + }).attach() + + setSupportActionBar(messagingMenu) + } + } + +} + +class MessagingPagerAdapter(fragmentActivity: FragmentActivity, private val numTabs: Int) : FragmentStateAdapter(fragmentActivity) { + + override fun getItemCount(): Int { + return numTabs + } + + override fun createFragment(position: Int): Fragment { + when (position) { + 0 -> return TeamsFragment() + 1 -> return SpacesFragment() + 2 -> return PeopleFragment() + 3 -> return MembershipFragment() + } + return Fragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt new file mode 100644 index 0000000..7fab255 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingModule.kt @@ -0,0 +1,49 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.MessageViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.SpaceDetailViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails.SpaceReadStatusDetailViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail.TeamDetailViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipViewModel +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val messagingModule = module { + viewModel { TeamsViewModel(get(), get()) } + viewModel { TeamDetailViewModel(get()) } + viewModel { TeamMembershipViewModel(get()) } + + single { TeamsRepository(get()) } + + + viewModel { SpacesViewModel(get(), get(), get(), get()) } + viewModel { SpaceDetailViewModel(get(), get(), get()) } + + single { SpacesRepository(get()) } + + viewModel { MembershipViewModel(get(), get()) } + + single { MembershipRepository(get()) } + + single { TeamMembershipRepository(get()) } + + viewModel { SpaceReadStatusDetailViewModel(get()) } + + viewModel { MessageViewModel(get()) } + + viewModel { MembershipReadStatusViewModel(get(), get()) } + + single { MessageComposerRepository(get()) } + + viewModel { MessageComposerViewModel(get(), get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt new file mode 100644 index 0000000..062c32f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/MessagingRepository.kt @@ -0,0 +1,149 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.net.Uri +import android.util.Log +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.message.Mention +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.MessageClient +import com.ciscowebex.androidsdk.message.RemoteFile +import io.reactivex.Emitter +import io.reactivex.Observable +import io.reactivex.Single +import java.io.File + +open class MessagingRepository(private val webex: Webex) { + val tag = "MessagingRepository" + + enum class FileDownloadEvent { + DOWNLOAD_COMPLETE, + DOWNLOAD_FAILED + } + + fun addSpace(spaceTitle: String, teamId: String?): Observable { + return Single.create { emitter -> + webex.spaces.create(spaceTitle, teamId, CompletionHandler { result -> + if (result.isSuccessful) { + val space = result.data + emitter.onSuccess(SpaceModel.convertToSpaceModel(space)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + + fun deleteMessage(messageId: String): Observable { + return Single.create { emitter -> + webex.messages.delete(messageId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun markMessageAsRead(spaceId: String, messageId: String? = null): Observable { + return Single.create { emitter -> + webex.messages.markAsRead(spaceId, messageId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getMessage(messageId: String): Observable { + return Single.create { emitter -> + webex.messages.get(messageId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(SpaceMessageModel.convertToSpaceMessageModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + + }) + }.toObservable() + } + + fun editMessage(messageId: String, messageText: Message.Text, mentions: ArrayList?): Observable { + return Single.create { emitter -> + webex.messages.get(messageId, CompletionHandler { messageResult -> + if (messageResult.isSuccessful) { + val message = messageResult.data + if (message != null) { + webex.messages.edit(message, messageText, mentions, CompletionHandler { result -> + if (result.isSuccessful) { + val messageObj = result.data + emitter.onSuccess(SpaceMessageModel.convertToSpaceMessageModel(messageObj)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + } else { + emitter.onError(Throwable("Error: Message cannot be found")) + } + } else { + emitter.onError(Throwable(messageResult.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun downloadThumbnail(remoteFile: RemoteFile, file: File): Observable { + return Single.create { emitter -> + webex.messages.downloadThumbnail(remoteFile, file, CompletionHandler { result -> + if (result.isSuccessful) { + if (result.data != null) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable("Unable to retrieve thumbnail")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + + fun downloadFile(remoteFile: RemoteFile, file: File, progressEmitter: Emitter, completionEmitter: Emitter>) { + webex.messages.downloadFile(remoteFile, file, + object : MessageClient.ProgressHandler { + override fun onProgress(bytes: Double) { + Log.d(tag, "downloadFile bytes: $bytes") + progressEmitter.onNext(bytes) + } + }, + CompletionHandler { fileUrlResult -> + if (fileUrlResult.isSuccessful) { + Log.d(tag, "downloadFile onComplete success: ${fileUrlResult.data}") + fileUrlResult.data?.let { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_COMPLETE, it.toString())) + } ?: run { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, "Download file error occurred")) + } + } else { + Log.d(tag, "downloadFile onComplete failed") + fileUrlResult.error?.let { + it.errorMessage?.let { errorMessage -> + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, errorMessage)) + } ?: run { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, "Download file error occurred")) + } + } ?: run { + completionEmitter.onNext(Pair(FileDownloadEvent.DOWNLOAD_FAILED, "Download file error occurred")) + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt new file mode 100644 index 0000000..901fd59 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/RemoteModel.kt @@ -0,0 +1,83 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import com.ciscowebex.androidsdk.message.RemoteFile +import com.ciscowebex.androidsdk.message.internal.RemoteFileImpl + +class RemoteFile(private val model: RemoteModel): RemoteFile { + + private var thumbnail: RemoteFile.Thumbnail? = null + class Thumbnail(private val width: Int, private val height: Int, private val mimeType: String, private val url: String): RemoteFile.Thumbnail { + override fun getWidth(): Int { + return width + } + + override fun getHeight(): Int { + return height + } + + override fun getMimeType(): String? { + return mimeType + } + + override fun getUrl(): String? { + return url + } + } + + init { + thumbnail = RemoteFileImpl.ThumbnailImpl(model.thumbnailWidth?:0, model.thumbnailHeight?:0, model.mimeType?:"", model.thumbnailUrl?:"") + } + + override fun getDisplayName(): String? { + return model.displayName + } + + override fun getSize(): Long { + return model.size ?: 0 + } + + override fun getMimeType(): String? { + return model.mimeType + } + + override fun getThumbnail(): RemoteFile.Thumbnail? { + return thumbnail + } + + override fun getUrl(): String? { + return model.url + } + + override fun getConversationId(): String? { + return model.conversationId + } + + override fun getMessageId(): String? { + return model.messageId + } + + override fun getContentIndex(): Int? { + return model.contentIndex + } + +} + +@Parcelize +class RemoteModel(val displayName: String?, + val mimeType: String?, + val size: Long?, + val url: String?, + val conversationId: String?, + var messageId: String?, + var contentIndex: Int?, + var thumbnailWidth: Int?, + var thumbnailHeight: Int?, + var thumbnailMimeType: String?, + val thumbnailUrl: String?) : Parcelable { + + fun getRemoteFile(): RemoteFile { + return RemoteFile(this) + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt new file mode 100644 index 0000000..4453cb0 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MentionsAutoCompleteEditText.kt @@ -0,0 +1,356 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import android.content.Context +import android.text.Editable +import android.text.Spannable +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.util.AttributeSet +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.widget.BaseAdapter +import android.widget.ListPopupWindow +import androidx.annotation.ColorInt +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemMentionBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.utf8Offset +import com.ciscowebex.androidsdk.message.Mention + +class MessageContent() { + var text: String = "" + var messageInputMentions: ArrayList? = null +} + +data class Filter(val text: String, val position: Int) + +interface AutoCompletePlugin { + fun onFilterChanged(text: String) + fun shouldTrigger(filter: Filter): Boolean + fun getAdapter(): BaseAdapter + fun itemSelected(position: Int, editText: MentionsAutoCompleteEditText, filter: Filter) + fun hasItems(): Boolean +} + +interface BackPressedListener { + fun onImeBack(editText: MentionsAutoCompleteEditText) +} + +interface CreateInputConnectionListener { + fun onCreateInputConnection(outAttrs: EditorInfo, inputConnection: InputConnection): InputConnection +} + +class MentionSpan(val context: Context, val mention: Mention) : ClickableSpan() { + override fun onClick(widget: View) { + } + + override fun updateDrawState(ds: TextPaint) { + ds.color = getMentionTextColor() + ds.isFakeBoldText = false + ds.bgColor = ContextCompat.getColor(context, R.color.blue_40) // add mentions highlight + } + + @ColorInt + private fun getMentionTextColor() = ContextCompat.getColor(context, R.color.white) +} + +class MentionsAutoCompleteEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : + AppCompatEditText(context, attrs, defStyle) { + + companion object { + val MAX_LINES = 4 + val TAG = MentionsAutoCompleteEditText::class.java.simpleName + } + + var backPressListener: BackPressedListener? = null + var createInputConnectionListener: CreateInputConnectionListener? = null + + var popup: ListPopupWindow? = null + val plugins: MutableList = arrayListOf() + + init { + } + + fun getMessageContent(): MessageContent { + return MessageContent().apply { + text = getText().toString() + if(getText().getSpans(0, getText().length, MentionSpan::class.java).isNotEmpty()){ + messageInputMentions = ArrayList() + } + for (span in getText().getSpans(0, getText().length, MentionSpan::class.java)) { + try { + span.mention.start = this.text.utf8Offset(span.mention.start) + span.mention.end = this.text.utf8Offset(span.mention.end) + messageInputMentions?.add(span.mention) + } catch (e: IndexOutOfBoundsException) { + Log.e(TAG, e.message) + // Remove mentions that exist outside the bounds of the message text + continue + } + } + } + } + + override fun getText(): Editable { + return super.getText() ?: Editable.Factory.getInstance().newEditable("") + } + + private fun dismissPopup() { + popup?.dismiss() + popup = null + } + + private fun getFilter(): Filter? { + val position = selectionStart + var start = 0 + for (i in (position - 1) downTo 0) { + if (Character.isWhitespace(text[i])) { + start = Math.min(i + 1, position) + break + } + } + if (text.getSpans(position, position, MentionSpan::class.java).isEmpty()) { + val s = text.subSequence(start, position).toString() + if (s.isNotEmpty()) { + return Filter(s, start) + } + } + return null + } + + override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + Log.e(TAG, "text : $text") + // Shift mention span ranges as needed when text is edited + if (text is Spannable) { + Log.e(TAG, "text is Spannable") + text.getSpans(0, text.length, MentionSpan::class.java).filter { span -> + span.mention.end.toInt() >= start + }.forEach { span -> + if (span.mention.start <= start && span.mention.end > start) { + + Log.e(TAG, "text.removeSpan") + text.removeSpan(span) + } else { + // Don't shift the start position if the user is editing a mention + if (span.mention.start.toInt() > start) { + span.mention.start += lengthAfter - lengthBefore + } + + // Don't shift the end of a span if that's the character that's being removed + if (span.mention.end.toInt() != start) { + span.mention.end += lengthAfter - lengthBefore + } + } + } + } + + val filter = getFilter() + + Log.e(TAG, "filter : ${filter?.text}") + if (filter == null) { + Log.e(TAG, "filter is null") + dismissPopup() + return + } + + val plugin = plugins.find { it.shouldTrigger(filter) } + + Log.e(TAG, "plugin : $plugin") + plugin?.apply { + onFilterChanged(filter.text) + if (plugin.hasItems()) { + + Log.e(TAG, "plugin hasItems()") + if (popup == null) { + + Log.e(TAG, "popup null showing popup") + popup = ListPopupWindow(context).apply { + anchorView = this@MentionsAutoCompleteEditText + setAdapter(plugin.getAdapter()) + setOnItemClickListener { _, _, position, _ -> + getFilter()?.apply { + plugin.itemSelected(position, this@MentionsAutoCompleteEditText, this) + } + dismissPopup() + } + show() + } + } + } else { + + Log.e(TAG, "plugin has no items, dismissPopup()") + dismissPopup() + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + dismissPopup() + } + + fun hasAutoCompletePlugin(): Boolean = plugins.size > 0 + + fun addAutoCompletePlugin(plugin: AutoCompletePlugin) { + plugins.removeAll { + it.javaClass.isInstance(plugin) + } + plugins.add(plugin) + } + + fun updateEditTextMaxLines(hasText: Boolean) { + maxLines = if (hasText) MAX_LINES else 1 + ellipsize = if (hasText) null else TextUtils.TruncateAt.END + } + + fun isEmpty(): Boolean { + return text.isEmpty() + } + + fun reset() { + setText("") + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + backPressListener?.apply { + onImeBack(this@MentionsAutoCompleteEditText) + } + } + return super.onKeyPreIme(keyCode, event) + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + val ic = super.onCreateInputConnection(outAttrs) + createInputConnectionListener?.apply { + return onCreateInputConnection(outAttrs, ic) + } + return ic + } +} + +class MentionsPlugin( + val lifecycleOwner: LifecycleOwner, + val context: Context, + val messageComposerViewModel: MessageComposerViewModel +) : AutoCompletePlugin { + val adapter = MentionsPopupAdapter() + + override fun getAdapter(): BaseAdapter = adapter + override fun hasItems(): Boolean = adapter.count > 0 + + override fun onFilterChanged(text: String) { + adapter.filter(text) + } + + override fun shouldTrigger(filter: Filter): Boolean { + val shouldTrigger = filter.text[0] == '@' + + Log.e(MentionsAutoCompleteEditText.TAG, "shouldTrigger : $shouldTrigger") + return shouldTrigger + } + + override fun itemSelected(position: Int, editText: MentionsAutoCompleteEditText, filter: Filter) { + val mentionable = adapter.getItem(position) as MembershipModel? ?: return + val shortText = mentionable.personFirstName + // Temporary work around for checking if there are duplicate first names, this would hopefully be something that would be returned to us + // by the view model + val text = if (adapter.firstNameCheck(shortText).size > 1) getFullNameIfLastFirstFormat(mentionable.personDisplayName) else shortText + editText.text.replace(filter.position, filter.position + filter.text.length, text) + val endMention = filter.position + text.length + if (editText.text.length == endMention || editText.text.length > endMention && !Character.isWhitespace(editText.text[endMention])) { + editText.text.insert(endMention, " ") + } + + val mention = when (position) { + 0 -> { + Mention.All(filter.position, endMention) + } + else -> { + Mention.Person(mentionable.personId).apply { + start = filter.position + end = endMention + } + } + } + val span = MentionSpan(context, mention) + + editText.text.setSpan(span, mention.start, mention.end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private fun getFullNameIfLastFirstFormat(displayName: String): String { + return if (displayName.contains(",")) { + displayName.split(",")[1].trim() + " " + displayName.split(",")[0].trim() + } else { + displayName + } + } + + inner class MentionsPopupAdapter : BaseAdapter() { + val inflator = LayoutInflater.from(context) + var mentions = mutableListOf() + var currentSearch = "" + + override fun getItem(position: Int): Any? { + if (position >= mentions.size) { + return null + } + return mentions[position] + } + + override fun getItemId(position: Int): Long { + if (position >= mentions.size) { + return 0L + } + return mentions[position].hashCode().toLong() + } + + override fun getCount(): Int = mentions.size + + fun filter(filter: String) { + mentions.clear() + notifyDataSetChanged() + currentSearch = filter + mentions = messageComposerViewModel.search(filter).toMutableList() + + Log.e(MentionsAutoCompleteEditText.TAG, "mentions.size : ${mentions.size}") + notifyDataSetChanged() + } + + fun firstNameCheck(firstName: String): MutableList = messageComposerViewModel.search(firstName).toMutableList() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + val viewHolder = if (convertView == null) { + ViewHolder(ListItemMentionBinding.inflate(inflator)) + } else { + convertView.tag as ViewHolder + } + return viewHolder.bind(mentions[position]) + } + + inner class ViewHolder(val binding: ListItemMentionBinding) { + init { + binding.root.tag = this + } + + fun bind(item: MembershipModel): View { + binding.apply { + lifecycleOwner = this@MentionsPlugin.lifecycleOwner + membership = item + } + binding.executePendingBindings() + return binding.root + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt new file mode 100644 index 0000000..02ec3c9 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerActivity.kt @@ -0,0 +1,422 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.Html +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMessageComposerBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogPostMessageHandlerBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemUploadAttachmentBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerViewModel.Companion.MINIMUM_MEMBERS_REQUIRED_FOR_MENTIONS +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.ReplyMessageModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.FileUtils.getUploadUriPath +import com.ciscowebex.androidsdk.kitchensink.utils.PermissionsHelper +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.hideKeyboard +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.message.LocalFile +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.utils.EmailAddress +import com.ciscowebex.androidsdk.utils.internal.MimeUtils +import org.koin.android.ext.android.inject +import java.io.File + + +class MessageComposerActivity : AppCompatActivity() { + + companion object { + enum class ComposerType { + POST_SPACE, + POST_PERSON_ID, + POST_PERSON_EMAIL + } + + enum class StyleType { + PLAIN_TEXT, + MARKDOWN_TEXT + } + + fun getIntent(context: Context, type: ComposerType, id: String, replyParentMessage: ReplyMessageModel?, messageId: String? = null): Intent { + val intent = Intent(context, MessageComposerActivity::class.java) + intent.putExtra(Constants.Intent.COMPOSER_TYPE, type) + intent.putExtra(Constants.Intent.COMPOSER_ID, id) + intent.putExtra(Constants.Intent.COMPOSER_REPLY_PARENT_MESSAGE, replyParentMessage) + intent.putExtra(Constants.Intent.MESSAGE_ID, messageId) + return intent + } + } + + private val tag = "MessageComposerActivity" + private val PICKFILE_REQUEST_CODE = 1005 + private lateinit var binding: ActivityMessageComposerBinding + private val messageComposerViewModel: MessageComposerViewModel by inject() + private val permissionsHelper: PermissionsHelper by inject() + private lateinit var composerType: ComposerType + private var id: String? = null + private var styleType = StyleType.PLAIN_TEXT + private lateinit var attachmentAdapter: UploadAttachmentsAdapter + private var isMentionsEnabled: Boolean = false + private var replyParentMessage: ReplyMessageModel? = null + // MessageId is not null in case of edit feature. + private var messageId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + composerType = intent.getSerializableExtra(Constants.Intent.COMPOSER_TYPE) as ComposerType + id = intent.getStringExtra(Constants.Intent.COMPOSER_ID) + replyParentMessage = intent.getParcelableExtra(Constants.Intent.COMPOSER_REPLY_PARENT_MESSAGE) + messageId = intent.getStringExtra(Constants.Intent.MESSAGE_ID) + + if (composerType == ComposerType.POST_SPACE) { + isMentionsEnabled = true + messageComposerViewModel.fetchAllMembersInSpace(id) + } + DataBindingUtil.setContentView(this, R.layout.activity_message_composer) + .also { binding = it } + .apply { + + plainRadioButton.isChecked = true + + sendButton.setOnClickListener { + sendButtonClicked() + } + + setUpObservers() + + if (messageId == null) { + attachmentButton.setOnClickListener { + val checkingPermission = checkReadStoragePermissions() + if (!checkingPermission) { + openFileExplorer() + } + } + } else { + // In case of edit we do not support editing attachments + attachmentButton.visibility = View.GONE + } + + radioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.plainRadioButton -> { + styleType = StyleType.PLAIN_TEXT + } + R.id.markdownRadioButton -> { + styleType = StyleType.MARKDOWN_TEXT + } + } + } + + val onAttachmentCrossClick: (File) -> Unit = { file -> + Log.d(tag, "onAttachmentCrossClick path: ${file.absolutePath}") + val position = attachmentAdapter.attachedFiles.indexOf(file) + attachmentAdapter.attachedFiles.removeAt(position) + attachmentAdapter.notifyItemRemoved(position) + } + + val dividerItemDecoration = DividerItemDecoration(this@MessageComposerActivity, LinearLayoutManager.VERTICAL) + attachmentRecyclerView.addItemDecoration(dividerItemDecoration) + attachmentAdapter = UploadAttachmentsAdapter(onAttachmentCrossClick) + attachmentRecyclerView.adapter = attachmentAdapter + } + + } + + private fun setUpObservers() { + messageComposerViewModel.postMessages.observe(this@MessageComposerActivity, Observer { + it?.let { + displayPostMessageHandler(it) + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_internal_error, "") + } + resetView() + }) + + messageComposerViewModel.postMessageError.observe(this@MessageComposerActivity, Observer { + it?.let { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_internal_error, it) + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_internal_error, "") + } + resetView() + }) + + messageComposerViewModel.fetchMembershipsLiveData.observe(this@MessageComposerActivity, Observer { memberships -> + memberships?.let { + if(isMentionsEnabled && it.size > MINIMUM_MEMBERS_REQUIRED_FOR_MENTIONS ) { + binding.message.addAutoCompletePlugin(MentionsPlugin(this@MessageComposerActivity, this, messageComposerViewModel)) + } + } + }) + + messageComposerViewModel.editMessage.observe(this@MessageComposerActivity, Observer { + it?.let { + showDialogWithMessage(this@MessageComposerActivity, null, getString(R.string.message_edit_successful)) + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, null, getString(R.string.edit_message_internal_error)) + } + resetView() + }) + } + + private fun openFileExplorer() { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + type = "*/*" + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + startActivityForResult(intent, PICKFILE_REQUEST_CODE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == PICKFILE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + data?.let { intent -> + intent.clipData?.let { data -> + for (index in 0 until data.itemCount) { + val uri = data.getItemAt(index).uri + addUriToList(uri) + } + } ?: run { + intent.data?.let { uri -> + addUriToList(uri) + } + } + } + } + } + + private fun addUriToList(uri: Uri) { + val filePath = getUploadUriPath(this@MessageComposerActivity, uri) + filePath?.let { + val file = File(it) + Log.d(tag, "PICKFILE_REQUEST_CODE filePath: $it") + Log.d(tag, "PICKFILE_REQUEST_CODE file Exist: ${file.exists()}") + + attachmentAdapter.attachedFiles.add(file) + attachmentAdapter.notifyDataSetChanged() + } + } + + private fun processAttachmentFiles(): ArrayList { + val files = ArrayList() + + for (file in attachmentAdapter.attachedFiles) { + var thumbnail: LocalFile.Thumbnail? = null + if (MimeUtils.getContentTypeByFilename(file.name) == MimeUtils.ContentType.IMAGE) { + thumbnail = LocalFile.Thumbnail(file, null, resources.getInteger(R.integer.attachment_thumbnail_width), resources.getInteger(R.integer.attachment_thumbnail_height)) + } + val localFile = LocalFile(file, null, thumbnail, null) + files.add(localFile) + } + + return files + } + + private fun sendButtonClicked() { + if (binding.message.text.isEmpty() && attachmentAdapter.attachedFiles.isEmpty()) { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_error, getString(R.string.post_message_empty_error)) + } else { + messageId?.let { + // Edit message flow + editMessage(it) } + ?: composerType.let { type -> + id?.let { + val files = processAttachmentFiles() + when (type) { + ComposerType.POST_SPACE -> { + postToSpace(it, files) + } + ComposerType.POST_PERSON_ID -> { + postPersonById(it, files) + } + ComposerType.POST_PERSON_EMAIL -> { + postPersonByEmail(it, files) + } + } + } + } + } + } + + private fun editMessage(messageId: String) { + val str = binding.message.text.toString() + val messageContent = binding.message.getMessageContent() + val text: Message.Text = if (styleType == StyleType.PLAIN_TEXT) { + Message.Text.plain(str) + } else { + Message.Text.markdown(str, null, null) + } + + messageComposerViewModel.editMessage(messageId, text, messageContent.messageInputMentions) + } + + private fun displayPostMessageHandler(message: Message) { + val builder: androidx.appcompat.app.AlertDialog.Builder = androidx.appcompat.app.AlertDialog.Builder(this) + + builder.setTitle(R.string.message_details) + + DialogPostMessageHandlerBinding.inflate(layoutInflater) + .apply { + messageData = message + val msg = message.getTextAsObject() + + msg.getMarkdown()?.let { + messageBodyTextView.text = Html.fromHtml(msg.getMarkdown(), Html.FROM_HTML_MODE_LEGACY) + } ?: run { + msg.getPlain()?.let { + messageBodyTextView.text = Html.fromHtml(msg.getPlain(), Html.FROM_HTML_MODE_LEGACY) + } + } + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + + builder.show() + } + } + + private fun postPersonByEmail(email: String, files: ArrayList?) { + val emailAddress = EmailAddress.fromString(email) + emailAddress?.let { + messageComposerViewModel.postToPerson(emailAddress, binding.message.text.toString(), styleType == StyleType.PLAIN_TEXT, files) + showProgress() + } ?: run { + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_error, getString(R.string.post_message_email_empty)) + } + } + + private fun postPersonById(personId: String, files: ArrayList?) { + messageComposerViewModel.postToPerson(personId, binding.message.text.toString(), styleType == StyleType.PLAIN_TEXT, files) + showProgress() + } + + private fun postToSpace(spaceId: String, files: ArrayList?) { + val messageContent = binding.message.getMessageContent() + + var progress = true + + replyParentMessage?.let { replyMessage -> + val str = binding.message.text.toString() + + val text: Message.Text? = if (styleType == StyleType.PLAIN_TEXT) { + Message.Text.plain(str) + } else { + Message.Text.markdown(str, null, null) + } + + text?.let { msgTxt -> + val draft = Message.draft(msgTxt) + + messageContent.messageInputMentions?.let { mentionsArray -> + for (item in mentionsArray) { + draft.addMentions(item) + } + } + + files?.let { filesArray -> + for (item in filesArray) { + draft.addAttachments(item) + } + } + + draft.setParent(replyMessage.getMessage()) + + messageComposerViewModel.postMessageDraft(spaceId, draft) + } ?: run { + progress = false + showDialogWithMessage(this@MessageComposerActivity, R.string.post_message_error, getString(R.string.post_message_invalid_message)) + } + } ?: run { + messageComposerViewModel.postToSpace(spaceId, binding.message.text.toString(), styleType == StyleType.PLAIN_TEXT, messageContent.messageInputMentions, files) + } + + if (progress) { + showProgress() + } + } + + private fun showProgress() { + binding.progressLayout.visibility = View.VISIBLE + } + + private fun hideProgress() { + binding.progressLayout.visibility = View.GONE + } + + private fun resetView() { + binding.message.text.clear() + hideKeyboard(binding.message) + attachmentAdapter.attachedFiles.clear() + attachmentAdapter.notifyDataSetChanged() + hideProgress() + } + + private fun checkReadStoragePermissions(): Boolean { + if (!permissionsHelper.hasReadStoragePermission()) { + Log.d(tag, "requesting read permission") + requestPermissions(PermissionsHelper.permissionForStorage(), PermissionsHelper.PERMISSIONS_STORAGE_REQUEST) + return true + } else { + Log.d(tag, "read permission granted") + } + return false + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + when (requestCode) { + PermissionsHelper.PERMISSIONS_STORAGE_REQUEST -> { + if (PermissionsHelper.resultForCallingPermissions(permissions, grantResults)) { + Log.d(tag, "read permission granted") + openFileExplorer() + } else { + Toast.makeText(this@MessageComposerActivity, getString(R.string.post_message_attach_permission_error), Toast.LENGTH_LONG).show() + } + } + } + } + + class UploadAttachmentsAdapter(private val onAttachmentCrossClick: (File) -> Unit) : RecyclerView.Adapter() { + var attachedFiles: MutableList = mutableListOf() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UploadAttachmentsAdapter.AttachmentViewHolder { + val binding = ListItemUploadAttachmentBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AttachmentViewHolder(binding, onAttachmentCrossClick) + } + + override fun getItemCount(): Int { + return attachedFiles.size + } + + override fun onBindViewHolder(holder: AttachmentViewHolder, position: Int) { + holder.bind(attachedFiles[position]) + } + + inner class AttachmentViewHolder(private val binding: ListItemUploadAttachmentBinding, private val onAttachmentCrossClick: (File) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.buttonLayout.setOnClickListener { + onAttachmentCrossClick(attachedFiles[adapterPosition]) + } + } + + fun bind(file: File) { + binding.name.text = file.name + binding.path.text = file.path + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt new file mode 100644 index 0000000..8f709bf --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerRepository.kt @@ -0,0 +1,77 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.message.LocalFile +import com.ciscowebex.androidsdk.message.Mention +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.utils.EmailAddress +import io.reactivex.Observable +import io.reactivex.Single + + +open class MessageComposerRepository(private val webex: Webex) { + + fun postToSpace(spaceId: String, message: String, plainText: Boolean, mentions: ArrayList?, files: ArrayList?): Observable { + return Single.create { emitter -> + val text: Message.Text? = if (plainText) { + Message.Text.plain(message) + } else { + Message.Text.markdown(message, null, null) + } + webex.messages.postToSpace(spaceId, text, mentions, files, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun postToPerson(email: EmailAddress, message: String, plainText: Boolean, files: ArrayList?): Observable { + return Single.create { emitter -> + val text: Message.Text? = if (plainText) { + Message.Text.plain(message) + } else { + Message.Text.markdown(message, null, null) + } + webex.messages.postToPerson(email, text, files, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun postToPerson(id: String, message: String, plainText: Boolean, files: ArrayList?): Observable { + return Single.create { emitter -> + val text: Message.Text? = if (plainText) { + Message.Text.plain(message) + } else { + Message.Text.markdown(message, null, null) + } + webex.messages.postToPerson(id, text, files, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun postMessageDraft(target: String, draft: Message.Draft): Observable { + return Single.create { emitter -> + webex.messages.post(target, draft, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data!!) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt new file mode 100644 index 0000000..c03204d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/composer/MessageComposerViewModel.kt @@ -0,0 +1,95 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.composer + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.message.LocalFile +import com.ciscowebex.androidsdk.message.Mention +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.utils.EmailAddress +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlin.collections.ArrayList +import java.util.Date + + +class MessageComposerViewModel(private val composerRepo: MessageComposerRepository, private val membershipRepo: MembershipRepository, + private val spacesRepository: SpacesRepository) : BaseViewModel() { + + companion object { + val MINIMUM_MEMBERS_REQUIRED_FOR_MENTIONS = 2 + } + private val tag = "MessageComposerViewModel" + + private val _postMessages = MutableLiveData() + val postMessages: LiveData = _postMessages + + private val _postMessageError = MutableLiveData() + val postMessageError: LiveData = _postMessageError + + private val _fetchMembershipsLiveData = MutableLiveData>() + val fetchMembershipsLiveData: LiveData> = _fetchMembershipsLiveData + + private val _editMessage = MutableLiveData() + val editMessage: LiveData = _editMessage + + private var membersList = mutableListOf() + + val labelAll = "All" + + fun postToSpace(spaceId: String, message: String, plainText: Boolean, mentions: ArrayList?, files: ArrayList? = null) { + composerRepo.postToSpace(spaceId, message, plainText, mentions, files).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun postToPerson(email: EmailAddress, message: String, plainText: Boolean, files: ArrayList? = null) { + composerRepo.postToPerson(email, message, plainText, files).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun postToPerson(id: String, message: String, plainText: Boolean, files: ArrayList? = null) { + composerRepo.postToPerson(id, message, plainText, files).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + Log.d(tag, "postToPersonID result: $result") + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun postMessageDraft(target: String, draft: Message.Draft) { + composerRepo.postMessageDraft(target, draft).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _postMessages.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun editMessage(messageId: String, messageText: Message.Text, mentions: ArrayList?) { + spacesRepository.editMessage(messageId, messageText, mentions).observeOn(AndroidSchedulers.mainThread()).subscribe({ result -> + _editMessage.postValue(result) + }, { error -> _postMessageError.postValue(error.message) }).autoDispose() + } + + fun fetchAllMembersInSpace(spaceId: String?, max: Int? = null) { + membershipRepo.getMembersInSpace(spaceId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ memberships -> + // Crete a membership model indicating all members + val allMember = MembershipModel(labelAll, "", "", labelAll, "", false, false, Date(), "", labelAll, "") + membersList.add(allMember) + membersList.addAll(memberships) + _fetchMembershipsLiveData.postValue(memberships) + }, { membersList.clear() }).autoDispose() + } + + fun search(filter: String): List { + return membersList.filter { + if (filter.isNotEmpty() && filter[0] == '@') { + it.personDisplayName.startsWith(filter.substring(1)) + } else { + it.personDisplayName.startsWith(filter) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt new file mode 100644 index 0000000..523886b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/MessagingSearchActivity.kt @@ -0,0 +1,23 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.ciscowebex.androidsdk.kitchensink.R + +/** + * Simple search activity that has Search Fragment inside + */ +class MessagingSearchActivity : AppCompatActivity() { + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, MessagingSearchActivity::class.java) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_search_messaging) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt new file mode 100644 index 0000000..493c8db --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleFragment.kt @@ -0,0 +1,163 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCommonBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemPersonsBinding +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import kotlinx.android.synthetic.main.fragment_common.* +import org.koin.android.ext.android.inject + +class SearchPeopleFragment : Fragment() { + private val searchPeopleViewModel: SearchPeopleViewModel by inject() + lateinit var personAdapter: SearchPersonAdapter + var listItemSize: Int = 0 + + companion object { + val TAG = SearchPeopleFragment::class.java.simpleName + + fun getInstance(): SearchPeopleFragment { + return SearchPeopleFragment() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return FragmentCommonBinding.inflate(inflater, container, false).apply { + recyclerView.itemAnimator = DefaultItemAnimator() + personAdapter = SearchPersonAdapter { selectedPerson -> + finishActivityAndReturnValue(selectedPerson) + } + recyclerView.adapter = personAdapter + listItemSize = resources.getInteger(R.integer.space_list_size) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + progressBar.visibility = View.VISIBLE + searchPeopleViewModel.loadData(newText, listItemSize) + return false + } + + }) + + setUpViewModelObservers() + + }.root + + } + + private fun finishActivityAndReturnValue(selectedPerson: PersonModel) { + val returnIntent = Intent() + returnIntent.putExtra(Constants.Intent.PERSON, selectedPerson) + activity?.setResult(RESULT_OK, returnIntent) + activity?.finish() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + searchPeopleViewModel.loadData("", listItemSize) + progress_bar.visibility = View.VISIBLE + } + + + private fun setUpViewModelObservers() { + // TODO: Put common code inside a function + searchPeopleViewModel.persons.observe(viewLifecycleOwner, Observer { personsList -> + personsList?.let { + if (it.isNotEmpty()) { + updateEmptyListUI(false) + personAdapter.personsList = it + personAdapter.notifyDataSetChanged() + } else { + updateEmptyListUI(true) + personAdapter.personsList = emptyList() + personAdapter.notifyDataSetChanged() + } + } + }) + searchPeopleViewModel.peopleError.observe(viewLifecycleOwner, Observer { error -> + error?.let { + personAdapter.personsList = emptyList() + personAdapter.notifyDataSetChanged() + showDialogWithMessage(R.string.error_occurred, it) + } + }) + } + + private fun showDialogWithMessage(titleResourceId: Int?, message: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(titleResourceId ?: R.string.message) + val tvMessage = TextView(requireContext()) + tvMessage.setPadding(10, 10, 10, 10) + tvMessage.text = message + + builder.setView(tvMessage) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun updateEmptyListUI(listEmpty: Boolean) { + progress_bar.visibility = View.GONE + if (listEmpty) { + tv_empty_data.visibility = View.VISIBLE + recycler_view.visibility = View.GONE + } else { + tv_empty_data.visibility = View.GONE + recycler_view.visibility = View.VISIBLE + } + } + + class SearchPersonAdapter(private val listItemClick: (PersonModel) -> Unit) : + RecyclerView.Adapter() { + var personsList: List = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, i: Int): ViewHolder { + return ViewHolder(ListItemPersonsBinding.inflate(LayoutInflater.from(parent.context), parent, false)) { position -> + listItemClick(personsList[position]) + } + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + viewHolder.bind(personsList[position]) + } + + override fun getItemCount(): Int { + return personsList.size + } + + inner class ViewHolder(val binding: ListItemPersonsBinding, val listItemClicked: (Int) -> Unit) : + RecyclerView.ViewHolder(binding.root) { + init { + binding.rootListItemPersonsView.setOnClickListener { + listItemClicked(adapterPosition) + } + } + + fun bind(itemModel: PersonModel) { + binding.listItem = itemModel + binding.executePendingBindings() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt new file mode 100644 index 0000000..07e3957 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleModule.kt @@ -0,0 +1,9 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val searchPeopleModule = module { + viewModel { SearchPeopleViewModel(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt new file mode 100644 index 0000000..00e1565 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/search/SearchPeopleViewModel.kt @@ -0,0 +1,33 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.search + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.isValidEmail +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers + +class SearchPeopleViewModel(private val peopleRepository: PersonRepository) : BaseViewModel() { + private val tag = "SearchPeopleViewModel" + private val _persons = MutableLiveData>() + val persons: LiveData> = _persons + private val _peopleError = MutableLiveData() + val peopleError: LiveData = _peopleError + + private val _searchResult = MutableLiveData>() + + fun loadData(key: String?, maxPeopleCount: Int) { + val observable: Observable> = if (key.isValidEmail()) + peopleRepository.getPeopleList(key, null, null, null, maxPeopleCount) + else + peopleRepository.getPeopleList(null, key, null, null, maxPeopleCount) + observable.observeOn(AndroidSchedulers.mainThread()).subscribe({ + _persons.postValue(it) + }, { + _peopleError.postValue(it.message) + }).autoDispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt new file mode 100644 index 0000000..f91bdda --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/AddPersonBottomSheetFragment.kt @@ -0,0 +1,35 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetAddMemberOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class AddPersonBottomSheetFragment(private val onOptionSelected: (Options) -> Unit) : BottomSheetDialogFragment() { + companion object { + val TAG = AddPersonBottomSheetFragment::class.java.simpleName + enum class Options { + ADD_BY_PERSON_ID, + ADD_BY_EMAIL_ID + } + } + + private lateinit var binding: BottomSheetAddMemberOptionsBinding + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetAddMemberOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + binding.addPersonByIdLabel.setOnClickListener { + onOptionSelected(Options.ADD_BY_PERSON_ID) + dismiss() + } + binding.addPersonByEmailLabel.setOnClickListener { + onOptionSelected(Options.ADD_BY_EMAIL_ID) + dismiss() + } + binding.cancel.setOnClickListener { + dismiss() + } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt new file mode 100644 index 0000000..339b8dc --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/ReplyMessageModel.kt @@ -0,0 +1,58 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.os.Parcelable +import com.ciscowebex.androidsdk.message.Message +import kotlinx.android.parcel.Parcelize + +class InternalMessage(private val model: ReplyMessageModel) : Message() { + override fun getId(): String? { + return model.messageId + } + + override fun getParentId(): String? { + return model.parentId + } + + override fun getSpaceId(): String? { + return model.spaceId + } + + override fun getCreated(): Long { + return model.created + } + + override fun isSelfMentioned(): Boolean { + return model.isSelfMentioned + } + + override fun isReply(): Boolean { + return model.isReply + } + + override fun getPersonId(): String? { + return model.personId + } + + override fun getPersonEmail(): String? { + return model.personEmail + } + + override fun getToPersonId(): String? { + return model.toPersonId + } + + override fun getToPersonEmail(): String? { + return model.toPersonEmail + } +} + +@Parcelize +class ReplyMessageModel(val spaceId: String, val messageId: String, + val created: Long, val isSelfMentioned: Boolean, val parentId: String, + val isReply: Boolean, val personId: String, + val personEmail: String, val toPersonId: String, val toPersonEmail: String) : Parcelable { + + fun getMessage(): Message { + return InternalMessage(this) + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt new file mode 100644 index 0000000..8c11d64 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceActionBottomSheetFragment.kt @@ -0,0 +1,58 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetSpaceOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class SpaceActionBottomSheetFragment(val editClickListener: (String, String) -> Unit, + val getMeetingInfoClickListener: (String) -> Unit, + val listMembersInSpaceClickListener: (String) -> Unit, + val deleteSpaceClickListener: (String, String) -> Unit, + val markSpaceReadClickListener: (String) -> Unit, + val showSpaceMembersWithReadStatusClickListener: (String) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetSpaceOptionsBinding + var spaceId: String = "" + var spaceTitle: String = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetSpaceOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + editSpaceName.setOnClickListener { + dismiss() + editClickListener(spaceId, spaceTitle) + } + + getMeetingInfo.setOnClickListener { + dismiss() + getMeetingInfoClickListener(spaceId) + } + + listMembersInSpace.setOnClickListener { + dismiss() + listMembersInSpaceClickListener(spaceId) + } + + showSpaceMembersWithReadStatus.setOnClickListener { + dismiss() + showSpaceMembersWithReadStatusClickListener(spaceId) + } + + markSpaceRead.setOnClickListener { + dismiss() + markSpaceReadClickListener(spaceId) + } + + deleteSpace.setOnClickListener { + dismiss() + deleteSpaceClickListener(spaceId, spaceTitle) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt new file mode 100644 index 0000000..d4302e5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMeetingInfo.kt @@ -0,0 +1,39 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.SpaceMeetingInfo + + +data class SpaceMeetingInfoModel(val spaceId: String, val meetingLink: String, val sipAddress: String, val meetingNumber: String, val callInTollFreeNumber: String, val callInTollNumber: String) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceMeetingInfo + + return spaceId == other.spaceId + } + + override fun hashCode(): Int { + var result = spaceId.hashCode() + result = 31 * result + meetingLink.hashCode() + result = 31 * result + sipAddress.hashCode() + result = 31 * result + meetingNumber.hashCode() + result = 31 * result + callInTollFreeNumber.hashCode() + result = 31 * result + callInTollNumber.hashCode() + return result + } + + override fun toString(): String { + return "Space Id: $spaceId\n\nMeeting Link: $meetingLink\n\nSIP Address: $sipAddress\n\nMeeting Number: $meetingNumber\n\nCall In Toll Free Number: $callInTollFreeNumber\n\nCall In Toll Number: $callInTollNumber" + } + + companion object { + fun convertToSpaceMeetingInfoModel(spaceMeetingInfo: SpaceMeetingInfo?): SpaceMeetingInfoModel { + return SpaceMeetingInfoModel(spaceMeetingInfo?.spaceId.orEmpty(), + spaceMeetingInfo?.meetingLink.orEmpty(), spaceMeetingInfo?.sipAddress.orEmpty(), + spaceMeetingInfo?.meetingNumber.orEmpty(), spaceMeetingInfo?.callInTollFreeNumber.orEmpty(), + spaceMeetingInfo?.callInTollNumber.orEmpty()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt new file mode 100644 index 0000000..3e61fbf --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceMessageModel.kt @@ -0,0 +1,26 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.Space.SpaceType +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.RemoteFile +import java.util.Date + +data class SpaceMessageModel(val spaceId: String, val messageId: String, val messageBody: Message.Text, + val created: Long, val isSelfMentioned: Boolean, val parentId: String, + val isReply: Boolean, val conversationType: SpaceType, val personId: String, + val personEmail: String, val toPersonId: String, val toPersonEmail: String, val attachments : List) { + + val createdDateTimeString: String = Date(created).toString() + var mMessage: Message? = null + companion object { + fun convertToSpaceMessageModel(message: Message?): SpaceMessageModel { + + val model = SpaceMessageModel(message?.getSpaceId().orEmpty(), message?.getId().orEmpty(), message?.getTextAsObject()?: Message.Text(), + message?.getCreated() ?: 0, message?.isSelfMentioned() ?: false, message?.getParentId().orEmpty(), + message?.isReply() ?: false, SpaceType.valueOf(message?.getSpaceType().toString()), message?.getPersonId().orEmpty(), + message?.getPersonEmail().orEmpty(), message?.getToPersonId().orEmpty(), message?.getToPersonEmail().orEmpty(), message?.getFiles().orEmpty()) + model.mMessage = message + return model + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt new file mode 100644 index 0000000..fc19471 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceModel.kt @@ -0,0 +1,45 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.Space +import com.ciscowebex.androidsdk.space.Space.SpaceType +import java.util.* + +data class SpaceModel(val id: String, val title: String, val spaceType: SpaceType, val isLocked: Boolean, val lastActivity: Date, val created: Date, val teamId: String, val sipAddress: String) { + + val createdDateTimeString: String = created.toString() + val lastActivityTimestampString: String = lastActivity.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceModel + + return id == other.id + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + spaceType.hashCode() + result = 31 * result + isLocked.hashCode() + result = 31 * result + lastActivity.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + teamId.hashCode() + result = 31 * result + sipAddress.hashCode() + result = 31 * result + createdDateTimeString.hashCode() + result = 31 * result + lastActivityTimestampString.hashCode() + return result + } + + companion object { + fun convertToSpaceModel(space: Space?): SpaceModel { + return SpaceModel(space?.id.orEmpty(), space?.title.orEmpty(), space?.type + ?: SpaceType.NONE, + space?.isLocked ?: false, space?.lastActivity + ?: Date(), space?.created ?: Date(), + space?.teamId.orEmpty(), space?.sipAddress.orEmpty()) + } + } + +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt new file mode 100644 index 0000000..36af047 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpaceReadStatusModel.kt @@ -0,0 +1,37 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.space.SpaceReadStatus +import com.ciscowebex.androidsdk.space.Space.SpaceType +import java.util.* + +data class SpaceReadStatusModel(val spaceId: String, val spaceType: SpaceType, val lastActivityDate: Date, val lastSeenDate: Date) { + val spaceTypeString: String = spaceType.name + val lastSeenDateTimeString: String = lastSeenDate.toString() + val lastActivityTimestampString: String = lastActivityDate.toString() + val isSpaceUnread: Boolean = lastActivityDate > lastSeenDate + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceReadStatus + + return spaceId == other.id + } + + override fun hashCode(): Int { + var result = spaceId.hashCode() + result = 31 * result + spaceType.hashCode() + result = 31 * result + lastActivityDate.hashCode() + result = 31 * result + lastSeenDate.hashCode() + return result + } + + companion object { + fun convertToSpaceReadStatusModel(spaceReadStatus: SpaceReadStatus?): SpaceReadStatusModel { + return SpaceReadStatusModel(spaceReadStatus?.id.orEmpty(),spaceReadStatus?.type + ?: SpaceType.NONE, spaceReadStatus?.lastActivityDate ?: Date(), + spaceReadStatus?.lastSeenDate ?: Date()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt new file mode 100644 index 0000000..939b3eb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesFragment.kt @@ -0,0 +1,313 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.util.Log +import android.view.* +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogCreateSpaceBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentSpacesBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.search.MessagingSearchActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters.SpaceReadStatusClientAdapter +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters.SpacesClientAdapter +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusActivity +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.space.Space +import org.koin.android.ext.android.inject + +class SpacesFragment : Fragment() { + private val TAG = SpacesFragment::class.java.simpleName + private val requestCodeSearchPersonToAddToSpace = 1919 + private lateinit var binding: FragmentSpacesBinding + private lateinit var spacesClientAdapter: SpacesClientAdapter + private val spacesReadClientAdapter: SpaceReadStatusClientAdapter = SpaceReadStatusClientAdapter() + + private val spacesViewModel: SpacesViewModel by inject() + + private var selectedSpaceListItem: SpaceModel? = null + private val addOnCallSuffix = "(On Call)" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentSpacesBinding.inflate(inflater, container, false).also { binding = it }.apply { + val optionsDialogFragment = SpaceActionBottomSheetFragment({ id, title -> showEditSpaceDialog(id, title) }, { id -> spacesViewModel.getMeetingInfo(id) }, + { id -> showMembersInSpace(id) }, { id, title -> showDeleteSpaceConfirmationDialog(id, title) }, { id -> markSpaceRead(id) }, { id -> showSpaceMembersWithReadStatus(id) }) + + spacesClientAdapter = SpacesClientAdapter(optionsDialogFragment, requireActivity().supportFragmentManager) { listItem -> + selectedSpaceListItem = listItem + startActivityForResult(context?.let { MessagingSearchActivity.getIntent(it) }, requestCodeSearchPersonToAddToSpace) + } + + setHasOptionsMenu(true) + + swipeContainer.setOnRefreshListener { + spacesViewModel.getSpacesList(resources.getInteger(R.integer.space_list_size)) + } + + setUpObservers() + + addSpacesFAB.setOnClickListener { + showAddSpaceDialog() + } + + spacesViewModel.getSpaceEvent()?.observe(viewLifecycleOwner, Observer { + when (it.first) { + WebexRepository.SpaceEvent.Updated -> { + if (it.second is Space) { + Log.d(TAG, "Space event ${(it.second as Space).title} is updated") + val space = SpaceModel.convertToSpaceModel(it.second as Space?) + val index = spacesClientAdapter.getPositionById(space.id) + if (!spacesClientAdapter.spaces.isNullOrEmpty() && index != -1) { + spacesClientAdapter.spaces[index] = space + spacesClientAdapter.notifyDataSetChanged() + } + } + } + WebexRepository.SpaceEvent.Created -> { + if (it.second is Space) { + val space = SpaceModel.convertToSpaceModel(it.second as Space?) + spacesClientAdapter.spaces.add(0, space) + spacesClientAdapter.notifyItemInserted(0) + Log.d(TAG, "Space event ${(it.second as Space).title} is created") + } + } + WebexRepository.SpaceEvent.CallStarted -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { + val index = spacesClientAdapter.getPositionById(it) + if (!spacesClientAdapter.spaces.isNullOrEmpty() && index != -1) { + val space = spacesClientAdapter.spaces[index] + Log.d(TAG, "Space event ${space} is CallStarted") + val inCallSpace = SpaceModel(spaceId, space.title + " " + addOnCallSuffix, space.spaceType, space.isLocked, space.lastActivity, space.created, space.teamId, space.sipAddress) + spacesClientAdapter.spaces[index] = inCallSpace + spacesClientAdapter.notifyItemChanged(index) + } + } + } + } + WebexRepository.SpaceEvent.CallEnded -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { + val index = spacesClientAdapter.getPositionById(it) + if (!spacesClientAdapter.spaces.isNullOrEmpty() && index != -1) { + val space = spacesClientAdapter.spaces[index] + Log.d(TAG, "Space event ${space.title} is CallEnded") + val inCallSpace = SpaceModel(spaceId, space.title.removeSuffix(addOnCallSuffix), space.spaceType, space.isLocked, space.lastActivity, space.created, space.teamId, space.sipAddress) + spacesClientAdapter.spaces[index] = inCallSpace + spacesClientAdapter.notifyItemChanged(index) + } + } + } + } + } + }) + }.root + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.messaging_menu, menu) + menu.getItem(0).isChecked = binding.spacesRecyclerView.adapter is SpaceReadStatusClientAdapter + + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + item.isChecked = !item.isChecked + if (item.isChecked) { + binding.spacesRecyclerView.adapter = spacesReadClientAdapter + } else { + binding.spacesRecyclerView.adapter = spacesClientAdapter + } + return super.onOptionsItemSelected(item) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val maxSpaces = resources.getInteger(R.integer.space_list_size) + binding.spacesRecyclerView.adapter = spacesClientAdapter + spacesViewModel.getSpacesList(maxSpaces) + spacesViewModel.getSpaceReadStatusList(maxSpaces) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == requestCodeSearchPersonToAddToSpace && resultCode == Activity.RESULT_OK) { + val person = data?.getParcelableExtra(Constants.Intent.PERSON) + if (person != null) { + showAddMembersOptionDialog(person) + } else { + Log.d(TAG, "No person selected!") + } + + } else { + Log.d(TAG, "Person could not be found!") + } + } + + private fun setUpObservers() { + spacesViewModel.readStatusList.observe(this@SpacesFragment.viewLifecycleOwner, Observer { list -> + list?.let { + spacesReadClientAdapter.spaceReadStatusList = it + spacesReadClientAdapter.notifyDataSetChanged() + } + }) + + spacesViewModel.spaces.observe(this@SpacesFragment.viewLifecycleOwner, Observer { spaces -> + spaces?.let { + binding.swipeContainer.isRefreshing = false + + spacesClientAdapter.spaces.clear() + spacesClientAdapter.spaces.addAll(it) + spacesClientAdapter.notifyDataSetChanged() + } + }) + + spacesViewModel.addSpace.observe(this@SpacesFragment.viewLifecycleOwner, Observer { addspace -> + addspace?.let { + spacesClientAdapter.spaces.add(it) + spacesClientAdapter.notifyDataSetChanged() + } + }) + + spacesViewModel.spaceMeetingInfo.observe(this@SpacesFragment.viewLifecycleOwner, Observer { info -> + info?.let { + showGetMeetingInfoDialog(it) + } + }) + + spacesViewModel.spaceError.observe(this@SpacesFragment.viewLifecycleOwner, Observer { error -> + error?.let { + binding.progressLayout.visibility = View.GONE + showDialogWithMessage(requireContext(), R.string.error_occurred, it) + } + }) + + spacesViewModel.createMemberData.observe(this@SpacesFragment.viewLifecycleOwner, Observer { data -> + data?.let { + binding.progressLayout.visibility = View.GONE + val message = "${it.personDisplayName} added to ${it.spaceId}" + showDialogWithMessage(requireContext(), R.string.success, message) + } + }) + + spacesViewModel.markSpaceRead.observe(this@SpacesFragment.viewLifecycleOwner, Observer { + binding.progressLayout.visibility = View.GONE + showDialogWithMessage(requireContext(), R.string.success, getString(R.string.space_marked_as_read)) + }) + + spacesViewModel.deleteSpace.observe(this@SpacesFragment.viewLifecycleOwner, Observer { spaceId -> + spaceId?.let { + binding.progressLayout.visibility = View.GONE + val index = spacesClientAdapter.getPositionById(it) + spacesClientAdapter.spaces.removeAt(index) + spacesClientAdapter.notifyItemRemoved(index) + } + }) + } + + // Dialog to display various options of adding person to space + private fun showAddMembersOptionDialog(person: PersonModel) { + val addMembersOptionDialog = AddPersonBottomSheetFragment { option -> + when (option) { + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_PERSON_ID -> selectedSpaceListItem?.id?.let { + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.createMembershipWithId(it, person.personId) + } + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_EMAIL_ID -> selectedSpaceListItem?.id?.let { + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.createMembershipWithEmailId(it, person.emails.first()) + } + } + } + activity?.supportFragmentManager?.let { addMembersOptionDialog.show(it, AddPersonBottomSheetFragment.TAG) } + } + + private fun showGetMeetingInfoDialog(spaceMeetingInfoModel: SpaceMeetingInfoModel) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.meeting_info) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = spaceMeetingInfoModel.toString() + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun showEditSpaceDialog(spaceId: String, title: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.edit_space) + val input = EditText(requireContext()) + input.text = SpannableStringBuilder(title) + input.requestFocus() + + builder.setView(input) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> spacesViewModel.updateSpace(spaceId, input.text.toString()) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + private fun showAddSpaceDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.add_space) + + DialogCreateSpaceBinding.inflate(layoutInflater) + .apply { + spaceTeamIdText.visibility = View.GONE + spaceTeamIdLabel.visibility = View.GONE + + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + spacesViewModel.addSpace(spaceTitleEditText.text.toString(), null) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + } + + private fun showDeleteSpaceConfirmationDialog(spaceId: String, spaceTitle: String) { + showDialogWithMessage(requireContext(), getString(R.string.delete_space), String.format(getString(R.string.delete_space_message, spaceTitle)), + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.delete(spaceId) + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + } + + private fun showMembersInSpace(spaceId: String) { + startActivity(MembershipActivity.getIntent(requireContext(), spaceId)) + } + + private fun showSpaceMembersWithReadStatus(spaceId: String) { + startActivity(MembershipReadStatusActivity.getIntent(requireContext(), spaceId)) + } + + private fun markSpaceRead(spaceId: String) { + binding.progressLayout.visibility = View.VISIBLE + spacesViewModel.markSpaceRead(spaceId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt new file mode 100644 index 0000000..dbc5b2b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesRepository.kt @@ -0,0 +1,139 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.space.Space.SpaceType +import com.ciscowebex.androidsdk.space.SpaceClient.SortBy +import io.reactivex.Observable +import io.reactivex.Single + +class SpacesRepository(private val webex: Webex) : MessagingRepository(webex) { + fun fetchSpacesList(teamId: String?, maxSpaces: Int): Observable> { + return Single.create> { emitter -> + webex.spaces.list(teamId, maxSpaces, SpaceType.NONE, SortBy.ID, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + SpaceModel.convertToSpaceModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun fetchSpaceById(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.get(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + val space = result.data + emitter.onSuccess(SpaceModel.convertToSpaceModel(space)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateSpace(spaceId: String, spaceName: String): Observable { + return Single.create { emitter -> + webex.spaces.update(spaceId, spaceName, CompletionHandler { result -> + if (result.isSuccessful) { + val space = result.data + emitter.onSuccess(SpaceModel.convertToSpaceModel(space)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun fetchSpaceReadStatusList(maxSpaces: Int): Observable> { + return Single.create> { emitter -> + webex.spaces.listWithReadStatus(maxSpaces, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + SpaceReadStatusModel.convertToSpaceReadStatusModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getMeetingInfo(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.getMeetingInfo(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(SpaceMeetingInfoModel.convertToSpaceMeetingInfoModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getSpaceReadStatusById(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.getWithReadStatus(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(SpaceReadStatusModel.convertToSpaceReadStatusModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun listMessages(spaceId: String): Observable> { + return Single.create> { emitter -> + webex.messages.list(spaceId, null, 50, null, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { SpaceMessageModel.convertToSpaceMessageModel(it) }.orEmpty()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(spaceId: String): Observable { + return Single.create { emitter -> + webex.spaces.delete(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun listSpacesWithActiveCalls(): Observable> { + return Single.create> { emitter -> + webex.spaces.listWithActiveCalls(CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data.orEmpty()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + // Commenting out but keeping this code for now in order to test encoding/decoding related code changes. + // We can delete once encoding/decoding code is put in proper format and returns proper error state(IN FORM OF ENUM) from Omnius layer + /*fun encodeDecodeTest() { + webex.base64Encode(ResourceType.Memberships, "Rohit Sharma", CompletionHandler { result -> + if(result.isSuccessful){ + Log.d("Enc/Dec Test", "Encoded String : ${result.data}") + val decodedString = webex.base64Decode(result.data?: "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi9lZGI2OWJlOS1hMDNiLTQ4YzUtYWFmYi1lMmE2MjE0N2Q0NmM") + Log.d("Enc/Dec Test", "Decoded String : $decodedString") + }else { + Log.d("Enc/Dec Test", "Error in encoding : ${result.error?.errorMessage}") + } + }) + }*/ +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt new file mode 100644 index 0000000..8966d16 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/SpacesViewModel.kt @@ -0,0 +1,130 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsRepository +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.rxkotlin.subscribeBy + +class SpacesViewModel(private val spacesRepo: SpacesRepository, + private val membershipRepo: MembershipRepository, + private val messagingRepo: TeamsRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val _spaces = MutableLiveData>() + val spaces: LiveData> = _spaces + + private val _readStatusList = MutableLiveData>() + val readStatusList: LiveData> = _readStatusList + + private val _addSpace = MutableLiveData() + val addSpace: LiveData = _addSpace + + private val _spaceMeetingInfo = MutableLiveData() + val spaceMeetingInfo: LiveData = _spaceMeetingInfo + + private val _spaceError = MutableLiveData() + val spaceError: LiveData = _spaceError + + private val _createMemberData = MutableLiveData() + val createMemberData: LiveData = _createMemberData + + private val _markSpaceRead = MutableLiveData() + val markSpaceRead: LiveData = _markSpaceRead + + private val _deleteSpace = MutableLiveData() + val deleteSpace: LiveData = _deleteSpace + + private val _spaceEventLiveData = MutableLiveData>() + + private val addOnCallSuffix = " (On Call)" + + init { + webexRepository._spaceEventLiveData = _spaceEventLiveData + } + + override fun onCleared() { + webexRepository.clearSpaceData() + } + + private fun getSpacesWithActiveCalls() { + val allSpaces = arrayListOf() + spacesRepo.listSpacesWithActiveCalls().observeOn(AndroidSchedulers.mainThread()).subscribe({ spaceIds -> + spaces.value?.forEach { space -> + if(spaceIds.contains(space.id)) { + val tempSpace = SpaceModel(space.id, space.title + addOnCallSuffix, space.spaceType, space.isLocked, space.lastActivity, space.created, space.teamId, space.sipAddress) + allSpaces.add(tempSpace) + } else { + allSpaces.add(space) + } + } + _spaces.postValue(allSpaces) + }) { _spaces.postValue(spaces.value)}.autoDispose() + } + + fun getSpaceEvent() = webexRepository._spaceEventLiveData + + fun getSpacesList(maxSpaces: Int) { + spacesRepo.fetchSpacesList(null, maxSpaces).observeOn(AndroidSchedulers.mainThread()).subscribe({ spacesList -> + _spaces.postValue(spacesList) + getSpacesWithActiveCalls() + }, { _spaces.postValue(emptyList()) }).autoDispose() + } + + fun addSpace(title: String, teamId: String?) { + spacesRepo.addSpace(title, teamId).observeOn(AndroidSchedulers.mainThread()).subscribe({ createdSpace -> + _addSpace.postValue(createdSpace) + }, { _addSpace.postValue(null) }).autoDispose() + + } + + fun getSpaceReadStatusList(maxSpaces: Int) { + spacesRepo.fetchSpaceReadStatusList(maxSpaces).observeOn(AndroidSchedulers.mainThread()).subscribe({ listReadStatus -> + _readStatusList.postValue(listReadStatus) + }, { _readStatusList.postValue(null) }).autoDispose() + } + + fun updateSpace(spaceId: String, spaceName: String) { + spacesRepo.updateSpace(spaceId, spaceName).observeOn(AndroidSchedulers.mainThread()).subscribe({ + Log.d(SpacesViewModel::class.java.simpleName, "Space title is updated") + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun delete(spaceId: String) { + spacesRepo.delete(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _deleteSpace.postValue(spaceId) + }, {error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun getMeetingInfo(spaceId: String) { + spacesRepo.getMeetingInfo(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ meetingInfo -> + _spaceMeetingInfo.postValue(meetingInfo) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun createMembershipWithId(spaceId: String, personId: String, isModerator: Boolean = false) { + membershipRepo.createMembershipWithId(spaceId, personId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun createMembershipWithEmailId(spaceId: String, emailId: String, isModerator: Boolean = false) { + membershipRepo.createMembershipWithEmail(spaceId, emailId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + fun markSpaceRead(spaceId: String) { + messagingRepo.markMessageAsRead(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ success -> + _markSpaceRead.postValue(success) + }, { error -> _spaceError.postValue(error.message) }).autoDispose() + } + + private fun refreshSpaces() { + getSpacesList(0) + } +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt new file mode 100644 index 0000000..8ebacd7 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpaceReadStatusClientAdapter.kt @@ -0,0 +1,37 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.* +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemSpacesReadClientBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceReadStatusModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails.SpaceReadStatusDetailActivity + +class SpaceReadStatusClientAdapter : RecyclerView.Adapter() { + var spaceReadStatusList: List = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpacesReadClientViewHolder { + return SpacesReadClientViewHolder(ListItemSpacesReadClientBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun getItemCount(): Int = spaceReadStatusList.size + + override fun onBindViewHolder(holder: SpacesReadClientViewHolder, position: Int) { + holder.bind(spaceReadStatusList[position]) + } + +} + +class SpacesReadClientViewHolder(private val binding: ListItemSpacesReadClientBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(spaceReadStatus: SpaceReadStatusModel) { + binding.spaceReadStatus = spaceReadStatus + + binding.spaceReadStatusClientLayout.setOnClickListener {view -> + ContextCompat.startActivity(view.context ,SpaceReadStatusDetailActivity.getIntent(view.context, spaceReadStatus.spaceId), null) + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt new file mode 100644 index 0000000..69171b3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/adapters/SpacesClientAdapter.kt @@ -0,0 +1,77 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemSpacesClientBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceActionBottomSheetFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail.SpaceDetailActivity + + +class SpacesClientAdapter(private val optionsDialogFragment: SpaceActionBottomSheetFragment, val supportFragmentManager: FragmentManager, + val onAddToSpaceButtonClicked: (SpaceModel) -> Unit) : RecyclerView.Adapter() { + var spaces: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpacesClientViewHolder { + return SpacesClientViewHolder(ListItemSpacesClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), + optionsDialogFragment, supportFragmentManager) { position -> + onAddToSpaceButtonClicked(spaces[position]) + } + } + + override fun getItemCount(): Int = spaces.size + + override fun onBindViewHolder(holder: SpacesClientViewHolder, position: Int) { + holder.bind(spaces[position]) + } + + fun getPositionById(spaceId: String): Int { + return spaces.indexOfFirst { it.id == spaceId } + } + +} + +class SpacesClientViewHolder(private val binding: ListItemSpacesClientBinding, + private val optionsDialogFragment: SpaceActionBottomSheetFragment, + private val supportFragmentManager: FragmentManager, + private val onAddToSpaceButtonClicked: (Int) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.ivAddToSpace.setOnClickListener { + onAddToSpaceButtonClicked(adapterPosition) + } + } + + fun bind(space: SpaceModel) { + binding.space = space + binding.spaceTitleLabel.setOnClickListener { view -> + startSpaceDetailActivity(view, space) + } + binding.spaceTitleTextView.setOnClickListener { view -> + startSpaceDetailActivity(view, space) + } + binding.spaceTitleLabel.setOnLongClickListener { view -> + showSpaceOptions(space, view) + } + binding.spaceTitleTextView.setOnLongClickListener { view -> + showSpaceOptions(space, view) + } + binding.executePendingBindings() + } + + private fun showSpaceOptions(space: SpaceModel, view: View): Boolean { + optionsDialogFragment.spaceId = space.id + optionsDialogFragment.spaceTitle = space.title + + optionsDialogFragment.show(supportFragmentManager, "Space Options") + + return true + } + + private fun startSpaceDetailActivity(view: View, space: SpaceModel) { + ContextCompat.startActivity(view.context, SpaceDetailActivity.getIntent(view.context, space.id), null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt new file mode 100644 index 0000000..a2b3c73 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/FileViewerActivity.kt @@ -0,0 +1,146 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.View +import android.webkit.MimeTypeMap +import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.BuildConfig +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityFileViewerBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.RemoteModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.FileUtils.getFile +import com.ciscowebex.androidsdk.kitchensink.utils.FileUtils.getThumbnailFile +import kotlinx.android.synthetic.main.activity_file_viewer.* +import org.koin.android.ext.android.inject +import java.io.File +import java.util.Locale + + +class FileViewerActivity : BaseActivity() { + + private var remoteModel: RemoteModel? = null + private lateinit var binding: ActivityFileViewerBinding + private val messageViewModel: MessageViewModel by inject() + + companion object { + fun getIntent(context: Context, remoteFile: RemoteModel): Intent { + val intent = Intent(context, FileViewerActivity::class.java) + intent.putExtra(Constants.Bundle.REMOTE_FILE, remoteFile) + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "FileViewerActivity" + + remoteModel = intent.getParcelableExtra(Constants.Bundle.REMOTE_FILE) + + DataBindingUtil.setContentView(this, R.layout.activity_file_viewer).also { + binding = it + remoteModel?.let { _remoteModel -> + val text = "${_remoteModel.getRemoteFile().getSize()} " + resources.getString(R.string.total_bytes) + totalBytesLabel.text = text + messageViewModel.downloadThumbnail(_remoteModel.getRemoteFile(), getThumbnailFile(applicationContext)) + setUpObservers() + } + }.apply { + btnDownload.setOnClickListener { + hideThumbnailView() + progressBar.visibility = View.VISIBLE + remoteModel?.let { _remoteModel -> + messageViewModel.downloadFile(_remoteModel.getRemoteFile(), getFile(applicationContext)) + } + } + } + } + + private fun setUpObservers() { + messageViewModel.error.observe(this, Observer { error -> + Toast.makeText(this, "Unable to get thumbnail, error: $error", Toast.LENGTH_LONG).show() + }) + + messageViewModel.thumbnailUri.observe(this, Observer { uri -> + uri?.let { + Log.d(tag, "thumbnail uri: $it") + progressBar.visibility = View.GONE + imgThumbnail.setImageURI(it) + } + }) + + messageViewModel.downloadFileCompletionLiveData.observe(this, Observer { + it?.let { _pair -> + downloadComplete(_pair) + } + }) + + messageViewModel.downloadFileProgressLiveData.observe(this, Observer { + it?.let { bytes -> + val text = "$bytes " + resources.getString(R.string.bytes_downloaded) + progressLabel.text = text + } + }) + } + + private fun downloadComplete(_pair: Pair) { + runOnUiThread { + progressBar.visibility = View.GONE + when (_pair.first) { + MessagingRepository.FileDownloadEvent.DOWNLOAD_COMPLETE -> { + Log.d(tag, "file downloaded at ${_pair.second}") + showDownloadedFile(_pair.second) + } + MessagingRepository.FileDownloadEvent.DOWNLOAD_FAILED -> { + val errorMsg = "file download failed :${_pair.second}" + Log.d(tag, errorMsg) + Toast.makeText(applicationContext, errorMsg, Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun showDownloadedFile(fileUrl: String?) { + fileUrl?.let { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(fileUrl).extension.toLowerCase(Locale.US)) + Log.d(tag, "mimetype: $mimeType") + if (mimeType != null) { + finish() + displayPdf(fileUrl, mimeType) + } + } + } + + private fun hideThumbnailView() { + imgThumbnail.visibility = View.GONE + btnDownload.visibility = View.GONE + } + + private fun getFileUri(context: Context, fileName: String): Uri? { + val file = File(fileName) + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) + } + + private fun displayPdf(fileName: String, mimeType: String) { + val uri = getFileUri(this, fileName) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, mimeType) + + // FLAG_GRANT_READ_URI_PERMISSION is needed on API 24+ so the activity opening the file can read it + intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_GRANT_READ_URI_PERMISSION + if (intent.resolveActivity(packageManager) == null) { + // Show an error + } else { + startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt new file mode 100644 index 0000000..14d56ae --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageActionBottomSheetFragment.kt @@ -0,0 +1,62 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetMessageOptionsBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.message.Message +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class MessageActionBottomSheetFragment(val deleteMessageClickListener: (SpaceMessageModel) -> Unit, + val markMessageAsReadClickListener: (SpaceMessageModel) -> Unit, + val replyMessageClickListener: (SpaceMessageModel) -> Unit, + val editMessageClickListener: (SpaceMessageModel) -> Unit) : BottomSheetDialogFragment() { + companion object { + val TAG = "MessageActionBottomSheetFragment" + var selfPersonId : String? = null + } + + private lateinit var binding: BottomSheetMessageOptionsBinding + lateinit var message: SpaceMessageModel + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetMessageOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + if(message.personId == selfPersonId) { + // Show delete message option for self messages + deleteMessage.visibility = View.VISIBLE + deleteMessage.setOnClickListener { + dismiss() + deleteMessageClickListener(message) + } + // Hide Mark Message Read option for self messages, as they would be in read status be default + markMessageAsRead.visibility = View.GONE + // Edit message allowed for self messages only + editMessage.visibility = View.VISIBLE + editMessage.setOnClickListener { + dismiss() + editMessageClickListener(message) + } + }else { + editMessage.visibility = View.GONE + deleteMessage.visibility = View.GONE + replyMessageSeparator.visibility = View.GONE + markMessageAsRead.visibility = View.VISIBLE + } + + markMessageAsRead.setOnClickListener { + dismiss() + markMessageAsReadClickListener(message) + } + + replyMessage.setOnClickListener { + dismiss() + replyMessageClickListener(message) + } + + cancel.setOnClickListener { dismiss() } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt new file mode 100644 index 0000000..3f47bc3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageDetailsDialogFragment.kt @@ -0,0 +1,131 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.os.Bundle +import android.text.Html +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogMessageDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemAttachmentsBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.BaseDialogFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.RemoteModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.RemoteFile +import kotlinx.android.synthetic.main.dialog_message_details.* +import org.koin.android.ext.android.inject + +class MessageDetailsDialogFragment : BaseDialogFragment() { + + companion object { + fun newInstance(messageId: String): MessageDetailsDialogFragment { + val args = Bundle() + args.putString(Constants.Bundle.MESSAGE_ID, messageId) + + val fragment = MessageDetailsDialogFragment() + fragment.arguments = args + + return fragment + } + } + + private val messageViewModel: MessageViewModel by inject() + private lateinit var messageId: String + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + messageId = arguments?.getString(Constants.Bundle.MESSAGE_ID) ?: "" + + return DialogMessageDetailsBinding.inflate(inflater, container, false) + .apply { + progressLayout.visibility = View.VISIBLE + + messageViewModel.message.observe(viewLifecycleOwner, Observer { _msg -> + _msg?.let { + progressLayout.visibility = View.GONE + message = it + setMessageBody(it.messageBody) + setUpAttachments(it.attachments) + } + }) + + close.setOnClickListener { dialog?.dismiss() } + }.root + } + + private fun setMessageBody(msg: Message.Text) { + var text = "" + when { + msg.getMarkdown() != null -> { + text = msg.getMarkdown()!! + } + msg.getPlain() != null -> { + text = msg.getPlain()!! + } + msg.getHtml() != null -> { + text = msg.getHtml()!! + } + } + messageBodyTextView.text = Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY) + } + + private fun setUpAttachments(attachments: List) { + attachmentTextView.text = getString(R.string.attachments_label, attachments.size) + + val dividerItemDecoration = DividerItemDecoration(requireContext(), + LinearLayoutManager.VERTICAL) + attachmentList.addItemDecoration(dividerItemDecoration) + val onAttachmentClick: (RemoteFile) -> Unit = { remoteFile -> + val remoteModel = RemoteModel(remoteFile.getDisplayName().orEmpty(), + remoteFile.getMimeType(), + remoteFile.getSize(), + remoteFile.getUrl(), + remoteFile.getConversationId(), + remoteFile.getMessageId(), + remoteFile.getContentIndex(), + remoteFile.getThumbnail()?.getWidth(), + remoteFile.getThumbnail()?.getHeight(), + remoteFile.getThumbnail()?.getMimeType(), + remoteFile.getThumbnail()?.getUrl()) + activity?.startActivity(FileViewerActivity.getIntent(requireContext(), remoteModel)) + } + attachmentList.adapter = MessageAttachmentsAdapter(attachments, onAttachmentClick) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + messageViewModel.getMessageDetail(messageId) + } + + class MessageAttachmentsAdapter(private val attachments: List, private val onAttachmentClick: (RemoteFile) -> Unit) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ListItemAttachmentsBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AttachmentViewHolder(binding, onAttachmentClick) + } + + override fun getItemCount(): Int { + return attachments.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as AttachmentViewHolder).bind(attachments[position]) + } + + inner class AttachmentViewHolder(private val binding: ListItemAttachmentsBinding, private val onAttachmentClick: (RemoteFile) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.root.setOnClickListener { + onAttachmentClick(attachments[adapterPosition]) + } + } + + fun bind(remoteFile: RemoteFile) { + binding.remoteFile = remoteFile + binding.executePendingBindings() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt new file mode 100644 index 0000000..e5f1807 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/MessageViewModel.kt @@ -0,0 +1,72 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import io.reactivex.Emitter +import io.reactivex.Observable +import io.reactivex.ObservableOnSubscribe +import io.reactivex.android.schedulers.AndroidSchedulers +import com.ciscowebex.androidsdk.message.RemoteFile +import java.io.File + +class MessageViewModel(private val spaceRepo: SpacesRepository) : BaseViewModel() { + private val tag = "MessageViewModel" + + private val _message = MutableLiveData() + val message: LiveData = _message + + private val _error = MutableLiveData() + val error: LiveData = _error + + private val _uri = MutableLiveData() + val thumbnailUri: LiveData = _uri + + private val _downloadFileCompletionLiveData = MutableLiveData>() + val downloadFileCompletionLiveData: LiveData> = _downloadFileCompletionLiveData + + private val _downloadFileProgressLiveData = MutableLiveData() + val downloadFileProgressLiveData: LiveData = _downloadFileProgressLiveData + + fun getMessageDetail(messageId: String) { + spaceRepo.getMessage(messageId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _message.postValue(it) + }, { error -> _error.postValue(error.message) }).autoDispose() + } + + fun downloadThumbnail(remoteFile: RemoteFile, file: File) { + spaceRepo.downloadThumbnail(remoteFile, file).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _uri.postValue(it) + }, { error -> _error.postValue(error.message) }).autoDispose() + } + + fun downloadFile(remoteFile: RemoteFile, file: File) { + lateinit var progressEmitter: Emitter + val progressObserver = Observable.create(ObservableOnSubscribe { emitter -> + progressEmitter = emitter + }) + + lateinit var completionEmitter: Emitter> + val completionObserver = Observable.create(ObservableOnSubscribe> { emitter -> + completionEmitter = emitter + }) + + progressObserver.observeOn(AndroidSchedulers.mainThread()).subscribe { + it?.let { + _downloadFileProgressLiveData.postValue(it) + } + }.autoDispose() + + completionObserver.observeOn(AndroidSchedulers.mainThread()).subscribe { + it?.let { + _downloadFileCompletionLiveData.postValue(it) + } + }.autoDispose() + + spaceRepo.downloadFile(remoteFile, file, progressEmitter, completionEmitter) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt new file mode 100644 index 0000000..76e5bc8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailActivity.kt @@ -0,0 +1,282 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Html +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySpaceDetailBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemSpaceMessageBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.ReplyMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.message.Message +import com.ciscowebex.androidsdk.message.RemoteFile +import org.koin.android.ext.android.inject + +class SpaceDetailActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String): Intent { + val intent = Intent(context, SpaceDetailActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + lateinit var messageClientAdapter: MessageClientAdapter + lateinit var binding: ActivitySpaceDetailBinding + + private val spaceDetailViewModel: SpaceDetailViewModel by inject() + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "SpaceDetailActivity" + + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + spaceDetailViewModel.spaceId = spaceId + DataBindingUtil.setContentView(this, R.layout.activity_space_detail) + .also { binding = it } + .apply { + val messageActionBottomSheetFragment = MessageActionBottomSheetFragment({ message -> spaceDetailViewModel.deleteMessage(message) }, + { message -> spaceDetailViewModel.markMessageAsRead(message) }, + { message -> replyMessageListener(message) }, + { message -> editMessage(message)}) + + messageClientAdapter = MessageClientAdapter(messageActionBottomSheetFragment, supportFragmentManager) + spaceMessageRecyclerView.adapter = messageClientAdapter + + setUpObservers() + + swipeContainer.setOnRefreshListener { + spaceDetailViewModel.getMessages() + } + postMessageFAB.setOnClickListener { + ContextCompat.startActivity(this@SpaceDetailActivity, + MessageComposerActivity.getIntent(this@SpaceDetailActivity, MessageComposerActivity.Companion.ComposerType.POST_SPACE, spaceDetailViewModel.spaceId, null), null) + } + } + } + + private fun replyMessageListener(message: SpaceMessageModel) { + val model = ReplyMessageModel( + message.spaceId, + message.messageId, + message.created, + message.isSelfMentioned, + message.parentId, + message.isReply, + message.personId, + message.personEmail, + message.toPersonId, + message.toPersonEmail) + ContextCompat.startActivity(this@SpaceDetailActivity, + MessageComposerActivity.getIntent(this@SpaceDetailActivity, MessageComposerActivity.Companion.ComposerType.POST_SPACE, spaceDetailViewModel.spaceId, model), null) + } + + private fun editMessage(message: SpaceMessageModel) { + startActivity(MessageComposerActivity.getIntent(this@SpaceDetailActivity, MessageComposerActivity.Companion.ComposerType.POST_SPACE, + spaceDetailViewModel.spaceId, null, message.messageId)) + } + + override fun onResume() { + super.onResume() + spaceDetailViewModel.getSpaceById() + getMessages() + } + + private fun getMessages() { + binding.noMessagesLabel.visibility = View.GONE + binding.progressLayout.visibility = View.VISIBLE + spaceDetailViewModel.getMessages() + } + + private fun setUpObservers() { + spaceDetailViewModel.space.observe(this@SpaceDetailActivity, Observer { + binding.space = it + }) + + spaceDetailViewModel.spaceMessages.observe(this@SpaceDetailActivity, Observer { list -> + list?.let { + binding.progressLayout.visibility = View.GONE + binding.swipeContainer.isRefreshing = false + + if (it.isEmpty()) { + binding.noMessagesLabel.visibility = View.VISIBLE + } else { + binding.noMessagesLabel.visibility = View.GONE + } + + messageClientAdapter.messages.clear() + messageClientAdapter.messages.addAll(it) + messageClientAdapter.notifyDataSetChanged() + } + }) + + spaceDetailViewModel.deleteMessage.observe(this@SpaceDetailActivity, Observer { model -> + model?.let { + val position = messageClientAdapter.messages.indexOf(it) + messageClientAdapter.messages.removeAt(position) + messageClientAdapter.notifyItemRemoved(position) + } + }) + + spaceDetailViewModel.messageError.observe(this@SpaceDetailActivity, Observer { errorMessage -> + errorMessage?.let { + showErrorDialog(it) + } + }) + + spaceDetailViewModel.markMessageAsReadStatus.observe(this@SpaceDetailActivity, Observer { model -> + model?.let { + showDialogWithMessage(this@SpaceDetailActivity, R.string.success, "Message with id ${it.messageId} marked as read") + } + }) + + spaceDetailViewModel.getMeData.observe(this@SpaceDetailActivity, Observer { model -> + model?.let { + MessageActionBottomSheetFragment.selfPersonId = it.personId + } + }) + + spaceDetailViewModel.messageEventLiveData.observe(this@SpaceDetailActivity, Observer { pair -> + if(pair != null) { + when (pair.first) { + WebexRepository.MessageEvent.Received -> { + Log.d(tag, "Message Received event fired!") + if(pair.second is Message) { + val message = pair.second as Message + // For replies, find parent and add to replies list at bottom. + if(message.isReply()){ + val parentMessagePosition = messageClientAdapter.getPositionById(message.getParentId()?: "") + // Ignore case when parent is not found, as parent might not be present in the list + if(parentMessagePosition != -1) { + if(parentMessagePosition == messageClientAdapter.messages.size - 1 ){ + messageClientAdapter.messages.add(SpaceMessageModel.convertToSpaceMessageModel(message)) + messageClientAdapter.notifyItemInserted(messageClientAdapter.messages.size - 1) + }else { + var positionToInsert = parentMessagePosition + 1 + for(i in (parentMessagePosition + 1) until messageClientAdapter.messages.size - 1) { + if (!messageClientAdapter.messages[i].isReply){ + positionToInsert = i; + break; + } + } + messageClientAdapter.messages.add(positionToInsert, SpaceMessageModel.convertToSpaceMessageModel(message)) + messageClientAdapter.notifyItemInserted(positionToInsert) + } + } + }else { + messageClientAdapter.messages.add(SpaceMessageModel.convertToSpaceMessageModel(message)) + messageClientAdapter.notifyItemInserted(messageClientAdapter.messages.size - 1) + } + } + } + WebexRepository.MessageEvent.Deleted -> { + if (pair.second is String?) { + Log.d(tag, "Message Deleted event fired!") + val position = messageClientAdapter.getPositionById(pair.second as String? ?: "") + if (!messageClientAdapter.messages.isNullOrEmpty() && position != -1) { + messageClientAdapter.messages.removeAt(position) + messageClientAdapter.notifyItemRemoved(position) + } + } + } + WebexRepository.MessageEvent.MessageThumbnailUpdated -> { + Log.d(tag, "Message ThumbnailUpdated event fired!") + val fileList: List? = pair.second as? List + if(!fileList.isNullOrEmpty()){ + for( thumbnail in fileList){ + Log.d(tag, "Message Updated thumbnail : ${thumbnail.getDisplayName()}") + } + } + + } + WebexRepository.MessageEvent.Edited -> { + if (pair.second is Message) { + val message = pair.second as Message + val position = messageClientAdapter.getPositionById(message.getId() ?: "") + if (!messageClientAdapter.messages.isNullOrEmpty() && position != -1) { + messageClientAdapter.messages[position] = SpaceMessageModel.convertToSpaceMessageModel(message) + messageClientAdapter.notifyItemChanged(position) + } + } + } + } + } + }) + } + +} + + +class MessageClientAdapter(private val messageActionBottomSheetFragment: MessageActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.Adapter() { + var messages: MutableList = mutableListOf() + + fun getPositionById(messageId: String): Int { + return messages.indexOfFirst { it.messageId == messageId } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageClientViewHolder { + return MessageClientViewHolder(ListItemSpaceMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false), + messageActionBottomSheetFragment, fragmentManager) + } + + override fun getItemCount(): Int = messages.size + + override fun onBindViewHolder(holder: MessageClientViewHolder, position: Int) { + holder.bind(messages[position]) + } + +} + +class MessageClientViewHolder(private val binding: ListItemSpaceMessageBinding, private val messageActionBottomSheetFragment: MessageActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.ViewHolder(binding.root) { + var messageItem: SpaceMessageModel? = null + val tag = "MessageClientViewHolder" + + init { + binding.membershipContainer.setOnClickListener { + messageItem?.let { message -> + MessageDetailsDialogFragment.newInstance(message.messageId).show(fragmentManager, "MessageDetailsDialogFragment") + } + } + } + + fun bind(message: SpaceMessageModel) { + binding.message = message + messageItem = message + binding.membershipContainer.setOnLongClickListener { view -> + messageActionBottomSheetFragment.message = message + messageActionBottomSheetFragment.show(fragmentManager, MessageActionBottomSheetFragment.TAG) + true + } + + when { + message.messageBody.getMarkdown() != null -> { + binding.messageTextView.text = Html.fromHtml(message.messageBody.getMarkdown(), Html.FROM_HTML_MODE_LEGACY) + } + message.messageBody.getPlain() != null -> { + binding.messageTextView.text = message.messageBody.getPlain() + } + else -> { + binding.messageTextView.text = "" + } + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt new file mode 100644 index 0000000..c155686 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/detail/SpaceDetailViewModel.kt @@ -0,0 +1,91 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.detail + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceMessageModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import com.ciscowebex.androidsdk.message.Message +import io.reactivex.android.schedulers.AndroidSchedulers + +class SpaceDetailViewModel(private val spacesRepo: SpacesRepository, private val personRepo: PersonRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val tag = "SpaceDetailViewModel" + lateinit var spaceId: String + private var person: PersonModel? = null + private val _deleteMessage = MutableLiveData() + val deleteMessage: LiveData = _deleteMessage + + private val _markMessageAsReadStatus = MutableLiveData() + val markMessageAsReadStatus: LiveData = _markMessageAsReadStatus + + private val _messageEventLiveData = MutableLiveData>() + val messageEventLiveData: LiveData> = _messageEventLiveData + + private val _getMeData = MutableLiveData() + val getMeData: LiveData = _getMeData + + init { + getMe() + webexRepository._messageEventLiveData = _messageEventLiveData + } + + override fun onCleared() { + super.onCleared() + webexRepository._messageEventLiveData = null + } + + private fun getMe() { + personRepo.getMe().observeOn(AndroidSchedulers.mainThread()).subscribe { + person = it + _getMeData.postValue(person) +// getMessages() + }.autoDispose() + } + + + private val _space = MutableLiveData() + val space: LiveData = _space + + private val _messageError = MutableLiveData() + val messageError: LiveData = _messageError + + private val _spaceMessages = MutableLiveData>() + val spaceMessages: LiveData> = _spaceMessages + + fun isSelfMessage(personId: String): Boolean { + return personId == person?.personId ?: false + } + + fun getPersonId(): String? { + return person?.personId + } + + fun getSpaceById() { + spacesRepo.fetchSpaceById(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ spaceModel -> + _space.postValue((spaceModel)) + }, { _space.postValue(null) }).autoDispose() + } + + fun getMessages() { + spacesRepo.listMessages(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ messageModels -> + _spaceMessages.postValue(messageModels) + }, { _spaceMessages.postValue(emptyList()) }).autoDispose() + } + + fun deleteMessage(message: SpaceMessageModel) { + spacesRepo.deleteMessage(message.messageId).observeOn(AndroidSchedulers.mainThread()).subscribe({ success -> + _deleteMessage.postValue(message) + }, { error -> _messageError.postValue(error?.message ?: "") }).autoDispose() + } + + fun markMessageAsRead(message: SpaceMessageModel) { + spacesRepo.markMessageAsRead(spaceId, message.messageId).observeOn(AndroidSchedulers.mainThread()).subscribe({ success -> + _markMessageAsReadStatus.postValue(message) + }, { error -> _messageError.postValue(error?.message ?: "") }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt new file mode 100644 index 0000000..1994efd --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipActivity.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + +class MembershipActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String): Intent { + val intent = Intent(context, MembershipActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + lateinit var binding: ActivityMembershipBinding + + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + + + DataBindingUtil.setContentView(this, R.layout.activity_membership) + .apply { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = MembershipFragment.newInstance(spaceId) + fragmentTransaction.add(R.id.membershipFragment, fragment) + fragmentTransaction.commit() + } + } +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt new file mode 100644 index 0000000..fa9423d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipFragment.kt @@ -0,0 +1,210 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogMembershipDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemMembershipClientBinding +import com.ciscowebex.androidsdk.kitchensink.person.PersonDialogFragment +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class MembershipFragment : Fragment() { + + lateinit var binding: FragmentMembershipBinding + + private val membershipViewModel: MembershipViewModel by inject() + private var spaceId: String? = null + + companion object { + fun newInstance(spaceId: String): MembershipFragment { + val args = Bundle() + args.putString(Constants.Bundle.SPACE_ID, spaceId) + + val fragment = MembershipFragment() + fragment.arguments = args + + return fragment + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + spaceId = arguments?.getString(Constants.Bundle.SPACE_ID) + + return FragmentMembershipBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + lifecycleOwner = this@MembershipFragment + val spaceMembershipActionBottomSheetFragment = SpaceMembershipActionBottomSheetFragment( + { membershipId -> membershipViewModel.getMembership(membershipId) }, + { membershipId -> membershipViewModel.updateMembershipWith(membershipId, true) }, + { membershipId -> membershipViewModel.updateMembershipWith(membershipId, false) }, + { personId -> showPersonDetails(personId) }, + { membershipId, position -> + showDialogWithMessage(requireContext(), getString(R.string.delete_membership), getString(R.string.confirm_delete_space_membership_action), + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + membershipViewModel.deleteMembership(position, membershipId) + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + }) + + val membershipClientAdapter = MembershipClientAdapter(spaceMembershipActionBottomSheetFragment, spaceId) + membershipsRecyclerView.adapter = membershipClientAdapter + membershipsRecyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + membershipViewModel.memberships.observe(this@MembershipFragment.viewLifecycleOwner, Observer { model -> + model?.let { + binding.progressLayout.visibility = View.GONE + membershipClientAdapter.memberships.clear() + membershipClientAdapter.memberships.addAll(it) + membershipClientAdapter.notifyDataSetChanged() + } + }) + + membershipViewModel.membershipDetail.observe(this@MembershipFragment.viewLifecycleOwner, Observer { model -> + model?.let { + getMembers() + displayMembershipDetail(it) + } + }) + + membershipViewModel.membershipError.observe(this@MembershipFragment.viewLifecycleOwner, Observer { error -> + error?.let { + showErrorDialog(it) + } + }) + + membershipViewModel.membershipEventLiveData.observe(this@MembershipFragment.viewLifecycleOwner, Observer { + if(it.second?.spaceId == spaceId) { + when (it.first) { + WebexRepository.MembershipEvent.Created -> { + membershipClientAdapter.memberships.add(0, MembershipModel.convertToMembershipModel(it.second)) + membershipClientAdapter.notifyItemInserted(0) + } + WebexRepository.MembershipEvent.Updated -> { + + val position = membershipClientAdapter.getPositionById(it.second?.id.orEmpty()) + if (!membershipClientAdapter.memberships.isNullOrEmpty() && position != -1) { + membershipClientAdapter.memberships[position] = MembershipModel.convertToMembershipModel(it.second) + membershipClientAdapter.notifyItemChanged(position) + } + Log.d(tag, "MembershipEvent - Update -> MembershipID : ${it.second?.id} , PersonID : ${it.second?.personId} ") + + } + WebexRepository.MembershipEvent.Deleted -> { + val position = membershipClientAdapter.getPositionById(it.second?.id.orEmpty()) + if (!membershipClientAdapter.memberships.isNullOrEmpty() && position != -1) { + membershipClientAdapter.memberships.removeAt(position) + membershipClientAdapter.notifyItemRemoved(position) + } + Log.d(tag, "MembershipEvent - Delete -> MembershipID : ${it.second?.id} , PersonID : ${it.second?.personId} ") + } + } + } + }) + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + getMembers() + } + + private fun getMembers() { + binding.progressLayout.visibility = View.VISIBLE + val maxMemberships = resources.getInteger(R.integer.membership_list_size) + membershipViewModel.getMembersIn(spaceId, maxMemberships) + } + + private fun displayMembershipDetail(membershipModel: MembershipModel) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.members_details) + + DialogMembershipDetailsBinding.inflate(layoutInflater) + .apply { + membership = membershipModel + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + + builder.show() + } + } + + private fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.error_occurred) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = errorMessage + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun showPersonDetails(personId: String) { + PersonDialogFragment.newInstance(personId).show(childFragmentManager, getString(R.string.person_detail)) + } +} + +class MembershipClientAdapter(private val spaceMembershipActionBottomSheetFragment: SpaceMembershipActionBottomSheetFragment, private val spaceId: String?) : RecyclerView.Adapter() { + var memberships: MutableList = mutableListOf() + + fun getPositionById(membershipId: String): Int { + return memberships.indexOfFirst { it.membershipId == membershipId } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MembershipClientViewHolder { + return MembershipClientViewHolder(ListItemMembershipClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), spaceMembershipActionBottomSheetFragment, spaceId) + } + + override fun getItemCount(): Int = memberships.size + + override fun onBindViewHolder(holder: MembershipClientViewHolder, position: Int) { + holder.bind(memberships[position]) + } + +} + +class MembershipClientViewHolder(private val binding: ListItemMembershipClientBinding, private val spaceMembershipActionBottomSheetFragment: SpaceMembershipActionBottomSheetFragment, private val spaceId: String?) : RecyclerView.ViewHolder(binding.root) { + fun bind(membership: MembershipModel) { + binding.membership = membership + + if (!spaceId.isNullOrEmpty()) { + binding.membershipContainer.setOnLongClickListener { view -> + spaceMembershipActionBottomSheetFragment.membershipId = membership.membershipId + spaceMembershipActionBottomSheetFragment.personId = membership.personId + spaceMembershipActionBottomSheetFragment.position = adapterPosition + + val activity = view.context as AppCompatActivity + activity.supportFragmentManager.let { spaceMembershipActionBottomSheetFragment.show(it, "Membership Options") } + + true + } + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt new file mode 100644 index 0000000..03cc465 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipModel.kt @@ -0,0 +1,45 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.membership.Membership +import java.util.* + +data class MembershipModel(val membershipId: String, val personId: String, val personEmail: String, + val personDisplayName: String, val spaceId: String, val isModerator: Boolean, + val isMonitor: Boolean, val created: Date, val personOrgId: String, val personFirstName: String, val personLastName: String) { + + val createdDateTimeString: String = created.toString() + val isModeratorString: String = isModerator.toString() + val isMonitorString: String = isMonitor.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceModel + + return membershipId == other.id + } + + override fun hashCode(): Int { + var result = membershipId.hashCode() + result = 31 * result + personId.hashCode() + result = 31 * result + personEmail.hashCode() + result = 31 * result + personDisplayName.hashCode() + result = 31 * result + spaceId.hashCode() + result = 31 * result + isModerator.hashCode() + result = 31 * result + isMonitor.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + personOrgId.hashCode() + return result + } + + companion object { + fun convertToMembershipModel(membership: Membership?): MembershipModel { + return MembershipModel(membership?.id.orEmpty(), membership?.personId.orEmpty(), membership?.personEmail.orEmpty(), + membership?.personDisplayName.orEmpty(), membership?.spaceId.orEmpty(), membership?.isModerator ?: false, + membership?.isMonitor ?: false, membership?.created ?: Date(), membership?.personOrgId.orEmpty(), + membership?.personFirstName.orEmpty(), membership?.personLastName.orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt new file mode 100644 index 0000000..bfa8fca --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipRepository.kt @@ -0,0 +1,98 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus.MembershipReadStatusModel +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class MembershipRepository(private val webex: Webex) { + fun getMembersInSpace(spaceId: String?, max: Int?): Observable> { + return Single.create> { emitter -> + webex.memberships.list(spaceId, null, null, max, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + MembershipModel.convertToMembershipModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getMembership(membershipId: String): Observable { + return Single.create { emitter -> + webex.memberships.get(membershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateMembershipWith(membershipId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.memberships.update(membershipId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(membershipId: String): Observable { + return Single.create { emitter -> + webex.memberships.delete(membershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithId(spaceId: String, personId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.memberships.create(spaceId, personId, null, false, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithEmail(spaceId: String, emailId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.memberships.create(spaceId, null, emailId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(MembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun listMembershipsWithReadStatus(spaceId: String): Observable> { + return Single.create> { emitter -> + webex.memberships.listWithReadStatus(spaceId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + MembershipReadStatusModel.convertToMembershipReadStatusModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt new file mode 100644 index 0000000..52cb76d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/MembershipViewModel.kt @@ -0,0 +1,59 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.membership.Membership +import io.reactivex.android.schedulers.AndroidSchedulers + +class MembershipViewModel(private val membershipRepo: MembershipRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val _memberships = MutableLiveData>() + val memberships: LiveData> = _memberships + + private val _membershipDetail = MutableLiveData() + val membershipDetail: LiveData = _membershipDetail + + private val _deleteMembership = MutableLiveData>() + val deleteMembership: LiveData> = _deleteMembership + + private val _membershipError = MutableLiveData() + val membershipError: LiveData = _membershipError + + private val _membershipEventLiveData = MutableLiveData>() + val membershipEventLiveData: LiveData> = _membershipEventLiveData + + init { + webexRepository._membershipEventLiveData = _membershipEventLiveData + } + + override fun onCleared() { + super.onCleared() + webexRepository._membershipEventLiveData = null + } + + fun getMembersIn(spaceId: String?, max: Int?) { + membershipRepo.getMembersInSpace(spaceId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ memberships -> + _memberships.postValue(memberships) + }, { _memberships.postValue(emptyList()) }).autoDispose() + } + + fun getMembership(membershipId: String) { + membershipRepo.getMembership(membershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, { error -> _membershipError.postValue(error.message) }).autoDispose() + } + + fun updateMembershipWith(membershipId: String, isModerator : Boolean) { + membershipRepo.updateMembershipWith(membershipId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, { error -> _membershipError.postValue(error.message) }).autoDispose() + } + + fun deleteMembership(itemPosition: Int, membershipId: String) { + membershipRepo.delete(membershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ response -> + _deleteMembership.postValue(Pair(response, itemPosition)) + }, { error -> _membershipError.postValue(error.message) }).autoDispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt new file mode 100644 index 0000000..fdb7410 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/SpaceMembershipActionBottomSheetFragment.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetSpaceMemberOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class SpaceMembershipActionBottomSheetFragment(val membershipDetailsClickListener: (String) -> Unit, val membershipSetModeratorClickListener: (String) -> Unit, + val membershipRemoveModeratorClickListener: (String) -> Unit, val showPersonDetails: (String) -> Unit, val deleteMembership: (String, Int) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetSpaceMemberOptionsBinding + var membershipId : String = "" + var personId: String = "" + var position: Int = -1 + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetSpaceMemberOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + getMembershipDetails.setOnClickListener { + dismiss() + membershipDetailsClickListener(membershipId) + } + + setMembershipModerator.setOnClickListener { + dismiss() + membershipSetModeratorClickListener(membershipId) + } + + removeMembershipModerator.setOnClickListener { + dismiss() + membershipRemoveModeratorClickListener(membershipId) + } + + getPersonDetails.setOnClickListener { + dismiss() + showPersonDetails(personId) + } + + deleteMembership.setOnClickListener { + dismiss() + deleteMembership(membershipId, position) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt new file mode 100644 index 0000000..0b9275b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusActivity.kt @@ -0,0 +1,41 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMembershipReadStatusBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + +class MembershipReadStatusActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String): Intent { + val intent = Intent(context, MembershipReadStatusActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + lateinit var binding: ActivityMembershipReadStatusBinding + + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + + + DataBindingUtil.setContentView(this, R.layout.activity_membership_read_status) + .apply { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = MembershipReadStatusFragment.newInstance(spaceId) + fragmentTransaction.add(R.id.fragment, fragment) + fragmentTransaction.commit() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt new file mode 100644 index 0000000..de842ca --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusFragment.kt @@ -0,0 +1,104 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentMembershipReadStatusBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemMembershipReadStatusBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class MembershipReadStatusFragment : Fragment() { + + lateinit var binding: FragmentMembershipReadStatusBinding + + private val membershipReadStatusViewModel: MembershipReadStatusViewModel by inject() + private var spaceId: String? = null + + companion object { + fun newInstance(spaceId: String): MembershipReadStatusFragment { + val args = Bundle() + args.putString(Constants.Bundle.SPACE_ID, spaceId) + val fragment = MembershipReadStatusFragment() + fragment.arguments = args + return fragment + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + spaceId = arguments?.getString(Constants.Bundle.SPACE_ID) + + return FragmentMembershipReadStatusBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + + + val membershipsReadStatusAdapter = MembershipReadStatusAdapter() + recyclerView.adapter = membershipsReadStatusAdapter + recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + membershipReadStatusViewModel.membershipsReadStatus.observe(this@MembershipReadStatusFragment.viewLifecycleOwner, Observer { + membershipsReadStatusAdapter.membershipsReadStatus.clear() + membershipsReadStatusAdapter.membershipsReadStatus.addAll(it) + membershipsReadStatusAdapter.notifyDataSetChanged() + binding.progressBar.visibility = View.GONE + }) + + membershipReadStatusViewModel.membershipReadStatusError.observe(this@MembershipReadStatusFragment.viewLifecycleOwner, Observer { + showDialogWithMessage(requireContext(), R.string.error_occurred, it) + binding.progressBar.visibility = View.GONE + }) + membershipReadStatusViewModel.membershipEventLiveData.observe(this@MembershipReadStatusFragment.viewLifecycleOwner, Observer { + if (it.second?.spaceId == spaceId) { + when (it.first) { + WebexRepository.MembershipEvent.MessageSeen -> { + getList() + } + + } + } + }) + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.progressBar.visibility = View.VISIBLE + getList() + } + + fun getList() { + membershipReadStatusViewModel.getMembershipsWithReadStatus(spaceId) + } + +} + +class MembershipReadStatusAdapter : RecyclerView.Adapter() { + var membershipsReadStatus: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MembershipReadStatusViewHolder { + return MembershipReadStatusViewHolder(ListItemMembershipReadStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun getItemCount(): Int = membershipsReadStatus.size + + override fun onBindViewHolder(holder: MembershipReadStatusViewHolder, position: Int) { + holder.bind(membershipsReadStatus[position]) + } + +} + +class MembershipReadStatusViewHolder(private val binding: ListItemMembershipReadStatusBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(membershipReadStatus: MembershipReadStatusModel) { + binding.membershipReadStatus = membershipReadStatus + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt new file mode 100644 index 0000000..e598e40 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusModel.kt @@ -0,0 +1,37 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipModel +import com.ciscowebex.androidsdk.membership.MembershipReadStatus + +data class MembershipReadStatusModel(val member: MembershipModel, val lastSeenId: String, val lastSeenDate: Long) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MembershipModel + + return member.membershipId == other.membershipId + } + + override fun hashCode(): Int { + var result = member.membershipId.hashCode() + result = 31 * result + member.personId.hashCode() + result = 31 * result + member.spaceId.hashCode() + result = 31 * result + member.personDisplayName.hashCode() + result = 31 * result + member.created.hashCode() + result = 31 * result + member.personOrgId.hashCode() + result = 31 * result + member.isModerator.hashCode() + result = 31 * result + member.isMonitor.hashCode() + result = 31 * result + member.personEmail.hashCode() + return result + } + + companion object { + fun convertToMembershipReadStatusModel(membershipReadStatus: MembershipReadStatus?): MembershipReadStatusModel { + return MembershipReadStatusModel(MembershipModel.convertToMembershipModel(membershipReadStatus?.membership), + membershipReadStatus?.lastSeenId.orEmpty(), membershipReadStatus?.lastSeenDate + ?: 0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt new file mode 100644 index 0000000..09770c9 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/members/membersReadStatus/MembershipReadStatusViewModel.kt @@ -0,0 +1,36 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.membersReadStatus + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.members.MembershipRepository +import com.ciscowebex.androidsdk.membership.Membership +import io.reactivex.android.schedulers.AndroidSchedulers + +class MembershipReadStatusViewModel(private val membershipRepo: MembershipRepository, private val webexRepository: WebexRepository) : BaseViewModel() { + private val _membershipsReadStatus = MutableLiveData>() + val membershipsReadStatus: LiveData> = _membershipsReadStatus + + private val _membershipReadStatusError = MutableLiveData() + val membershipReadStatusError: LiveData = _membershipReadStatusError + + private val _membershipEventLiveData = MutableLiveData>() + val membershipEventLiveData: LiveData> = _membershipEventLiveData + + init { + webexRepository._membershipEventLiveData = _membershipEventLiveData + } + + override fun onCleared() { + super.onCleared() + webexRepository._membershipEventLiveData = null + } + + fun getMembershipsWithReadStatus(spaceId: String?) { + membershipRepo.listMembershipsWithReadStatus(spaceId + ?: "").observeOn(AndroidSchedulers.mainThread()).subscribe({ membershipsReadStatus -> + _membershipsReadStatus.postValue(membershipsReadStatus) + }, { _membershipReadStatusError.postValue(it.message) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt new file mode 100644 index 0000000..66e752f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailActivity.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySpaceReadStatusDetailBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + +class SpaceReadStatusDetailActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, spaceId: String) : Intent { + val intent = Intent(context, SpaceReadStatusDetailActivity::class.java) + intent.putExtra(Constants.Intent.SPACE_ID, spaceId) + return intent + } + } + + private val spaceReadStatusDetailViewModel : SpaceReadStatusDetailViewModel by inject() + private lateinit var spaceId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + spaceId = intent.getStringExtra(Constants.Intent.SPACE_ID) ?: "" + + DataBindingUtil.setContentView(this, R.layout.activity_space_read_status_detail) + .apply { + progressLayout.visibility = View.VISIBLE + + spaceReadStatusDetailViewModel.spaceReadStatus.observe(this@SpaceReadStatusDetailActivity, Observer { model -> + model?.let { + progressLayout.visibility = View.GONE + spaceReadStatus = it + } + }) + } + } + + override fun onResume() { + super.onResume() + spaceReadStatusDetailViewModel.getSpaceReadStatusById(spaceId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt new file mode 100644 index 0000000..dcd1d3b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/spaces/readStatusDetails/SpaceReadStatusDetailViewModel.kt @@ -0,0 +1,19 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.spaces.readStatusDetails + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceReadStatusModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import io.reactivex.android.schedulers.AndroidSchedulers + +class SpaceReadStatusDetailViewModel(private val spacesRepo: SpacesRepository) : BaseViewModel() { + private val _spaceReadStatus = MutableLiveData() + val spaceReadStatus : LiveData = _spaceReadStatus + + fun getSpaceReadStatusById(spaceId: String){ + spacesRepo.getSpaceReadStatusById(spaceId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _spaceReadStatus.postValue(it) + }, {_spaceReadStatus.postValue(null)}).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt new file mode 100644 index 0000000..af3edb5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamActionBottomSheetFragment.kt @@ -0,0 +1,47 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetTeamOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class TeamActionBottomSheetFragment( + val editClickListener: (String, String) -> Unit, + val addSpaceClickListener : (String) -> Unit, + val deleteTeamClickListener : (String, String) -> Unit, + val getMembersClickListener : (String) -> Unit +) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetTeamOptionsBinding + var teamId : String = "" + var teamTitle: String = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetTeamOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + getMembers.setOnClickListener { + dismiss() + getMembersClickListener(teamId) + } + editTeamName.setOnClickListener { + dismiss() + editClickListener(teamId, teamTitle) + } + + addSpaceFromTeam.setOnClickListener { + dismiss() + addSpaceClickListener(teamId) + } + + deleteTeam.setOnClickListener { + dismiss() + deleteTeamClickListener(teamId, teamTitle) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt new file mode 100644 index 0000000..74831b6 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamModel.kt @@ -0,0 +1,26 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import java.util.Date + +data class TeamModel(val id : String, val name : String, val createdDateTime : Date){ + + val createdDateTimeString : String = createdDateTime.toString() + + override fun equals(other: Any?): Boolean { + if(this === other) return true + if(javaClass != other?.javaClass) return false + + other as TeamModel + + return id == other.id + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + createdDateTime.hashCode() + result = 31 * result + createdDateTimeString.hashCode() + return result + } + +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt new file mode 100644 index 0000000..c527498 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsFragment.kt @@ -0,0 +1,254 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogCreateSpaceBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentTeamsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemTeamsClientBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.search.MessagingSearchActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.AddPersonBottomSheetFragment +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail.TeamDetailActivity +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipActivity +import com.ciscowebex.androidsdk.kitchensink.person.PersonModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + + +class TeamsFragment : Fragment() { + private lateinit var binding: FragmentTeamsBinding + private lateinit var teamsClientAdapter: TeamsClientAdapter + + private val teamsViewModel: TeamsViewModel by inject() + private val TAG = TeamsFragment::class.java.name + private val requestCodeSearchPersonToAddToTeam = 31321 + private var selectedTeamListItem: TeamModel? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentTeamsBinding.inflate(inflater, container, false).also { binding = it }.apply { + val optionsDialogFragment = TeamActionBottomSheetFragment( + { id, title -> showEditTeamDialog(id, title) }, + { id -> showAddSpaceDialog(id) }, + { id, title -> showDeleteTeamConfirmationDialog(id, title) }, + { id -> showMembers(id) } + ) + teamsClientAdapter = TeamsClientAdapter(optionsDialogFragment) { position -> + selectedTeamListItem = teamsClientAdapter.teams[position] + startActivityForResult(context?.let { MessagingSearchActivity.getIntent(it) }, requestCodeSearchPersonToAddToTeam) + } + + teamsRecyclerView.adapter = teamsClientAdapter + lifecycleOwner = this@TeamsFragment + + swipeContainer.setOnRefreshListener { + teamsViewModel.getTeamsList(resources.getInteger(R.integer.team_list_size)) + } + + teamsViewModel.teams.observe(this@TeamsFragment.viewLifecycleOwner, Observer { list -> + list?.let { + swipeContainer.isRefreshing = false + + teamsClientAdapter.teams.clear() + teamsClientAdapter.teams.addAll(it) + teamsClientAdapter.notifyDataSetChanged() + } + }) + + teamsViewModel.teamAdded.observe(this@TeamsFragment.viewLifecycleOwner, Observer { model -> + model?.let { + teamsClientAdapter.teams.add(it) + teamsClientAdapter.notifyDataSetChanged() + } + }) + + teamsViewModel.teamError.observe(this@TeamsFragment.viewLifecycleOwner, Observer { error -> + error?.let { + showErrorDialog(it) + } + }) + + addTeamsFAB.setOnClickListener { + showAddTeamDialog() + } + + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + teamsViewModel.getTeamsList(resources.getInteger(R.integer.team_list_size)) + } + + private fun showAddTeamDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.add_team) + val input = EditText(requireContext()) + input.hint = getString(R.string.team_name_hint) + input.requestFocus() + + builder.setView(input) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> teamsViewModel.addTeam(input.text.toString()) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + private fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.error_occurred) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = errorMessage + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + private fun showEditTeamDialog(teamID: String, title: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.edit_team) + val input = EditText(requireContext()) + input.text = SpannableStringBuilder(title) + input.requestFocus() + + builder.setView(input) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> teamsViewModel.updateTeam(teamID, input.text.toString()) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + + private fun showAddSpaceDialog(teamId: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.add_space) + + DialogCreateSpaceBinding.inflate(layoutInflater) + .apply { + spaceTeamIdText.text = teamId + + spaceTitleEditText.requestFocus() + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { _, _ -> + teamsViewModel.addSpaceFromTeam(spaceTitleEditText.text.toString(), teamId) + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + + builder.show() + } + } + + private fun showMembers(teamId: String) { + startActivity(TeamMembershipActivity.getIntent(requireContext(), teamId)) + } + + private fun showDeleteTeamConfirmationDialog(teamId: String, teamTitle: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.delete_team_confirm) + val message = TextView(requireContext()) + message.setPadding(10, 10, 10, 10) + message.text = String.format(getString(R.string.delete_team_message), teamTitle) + + builder.setView(message) + + builder.setPositiveButton(android.R.string.ok) { _, _ -> teamsViewModel.deleteTeamWithId(teamId) } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.show() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == requestCodeSearchPersonToAddToTeam && resultCode == Activity.RESULT_OK) { + val person = data?.getParcelableExtra(Constants.Intent.PERSON) + if (person != null) { + showAddMembersOptionDialog(person) + } else { + Log.d(TAG, "Person data is null ") + } + + } else { + Log.d(TAG, "Person could not be found!") + } + } + + private fun showAddMembersOptionDialog(person: PersonModel) { + val addMembersOptionDialog = AddPersonBottomSheetFragment { option -> + when (option) { + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_PERSON_ID -> selectedTeamListItem?.id?.let { + teamsViewModel.createMembershipWithId(it, person.personId, false) + } + AddPersonBottomSheetFragment.Companion.Options.ADD_BY_EMAIL_ID -> selectedTeamListItem?.id?.let { + teamsViewModel.createMembershipWithEmailId(it, person.emails.first(), false) + } + } + } + activity?.supportFragmentManager?.let { addMembersOptionDialog.show(it, AddPersonBottomSheetFragment.TAG) } + } +} + +class TeamsClientAdapter(private val optionsDialogFragment: TeamActionBottomSheetFragment, private val onAddToTeamButtonClicked: (Int) -> Unit) : RecyclerView.Adapter() { + var teams: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TeamsClientViewHolder { + return TeamsClientViewHolder(ListItemTeamsClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), optionsDialogFragment, onAddToTeamButtonClicked) + } + + override fun getItemCount(): Int = teams.size + + override fun onBindViewHolder(holder: TeamsClientViewHolder, position: Int) { + holder.bind(teams[position]) + } +} + +class TeamsClientViewHolder(private val binding: ListItemTeamsClientBinding, private val optionsDialogFragment: TeamActionBottomSheetFragment, + private val onAddToTeamButtonClicked: (Int) -> Unit) : RecyclerView.ViewHolder(binding.root) { + init { + binding.ivAddToTeam.setOnClickListener { + onAddToTeamButtonClicked(adapterPosition) + } + } + + fun bind(team: TeamModel) { + binding.team = team + + binding.teamsClientLayout.setOnClickListener { view -> + ContextCompat.startActivity(view.context, TeamDetailActivity.getIntent(view.context, team.id), null) + } + + binding.teamsClientLayout.setOnLongClickListener { view -> + optionsDialogFragment.teamId = team.id + optionsDialogFragment.teamTitle = team.name + + val activity = view.context as AppCompatActivity + activity.supportFragmentManager.let { optionsDialogFragment.show(it, "Team Options") } + + true + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt new file mode 100644 index 0000000..0aaf1ea --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsRepository.kt @@ -0,0 +1,78 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.MessagingRepository +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Completable +import io.reactivex.Observable +import io.reactivex.Single +import java.util.* + +class TeamsRepository(private val webex: Webex) : MessagingRepository(webex) { + fun fetchTeamsList(maxTeams: Int): Observable> { + return Single.create> { emitter -> + webex.teams.list(maxTeams, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.filter { !it.isDeleted }?.map { TeamModel(it.id.orEmpty(), it.name.orEmpty(), it.created) } + ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun fetchTeamById(teamId: String): Observable { + return Single.create { emitter -> + webex.teams.get(teamId, CompletionHandler { result -> + if (result.isSuccessful) { + val team = result.data + emitter.onSuccess(TeamModel(team?.id.orEmpty(), team?.name.orEmpty(), team?.created + ?: Date())) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createTeam(teamName: String): Observable { + return Single.create { emitter -> + webex.teams.create(teamName, CompletionHandler { result -> + if (result.isSuccessful) { + val team = result.data + emitter.onSuccess(TeamModel(team?.id.orEmpty(), team?.name.orEmpty(), team?.created + ?: Date())) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateTeam(teamId: String, teamName: String): Observable { + return Single.create { emitter -> + webex.teams.update(teamId, teamName, CompletionHandler { result -> + if (result.isSuccessful) { + val team = result.data + emitter.onSuccess(TeamModel(team?.id.orEmpty(), team?.name.orEmpty(), team?.created + ?: Date())) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun deleteTeamWithId(teamId: String): Observable { + return Completable.create { emitter -> + webex.teams.delete(teamId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onComplete() + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt new file mode 100644 index 0000000..b6b6460 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/TeamsViewModel.kt @@ -0,0 +1,68 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership.TeamMembershipRepository +import io.reactivex.android.schedulers.AndroidSchedulers + +class TeamsViewModel(private val teamsRepo: TeamsRepository, private val membershipRepo: TeamMembershipRepository) : BaseViewModel() { + private val _teams = MutableLiveData>() + val teams: LiveData> = _teams + + private val _teamAdded = MutableLiveData() + val teamAdded: LiveData = _teamAdded + + private val _createMemberData = MutableLiveData() + val createMemberData: LiveData = _createMemberData + + private val _teamError = MutableLiveData() + val teamError : LiveData = _teamError + + fun getTeamsList(maxTeams: Int) { + teamsRepo.fetchTeamsList(maxTeams).observeOn(AndroidSchedulers.mainThread()).subscribe({ teamsList -> + _teams.postValue(teamsList) + }, { _teams.postValue(emptyList()) }).autoDispose() + } + + fun addTeam(teamName: String) { + teamsRepo.createTeam(teamName).observeOn(AndroidSchedulers.mainThread()).subscribe({ addedTeam -> + _teamAdded.postValue(addedTeam) + }, { _teamAdded.postValue(null) }).autoDispose() + } + + fun updateTeam(teamId: String, teamName: String) { + teamsRepo.updateTeam(teamId, teamName).observeOn(AndroidSchedulers.mainThread()).subscribe({ + refreshTeams() + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } + + fun deleteTeamWithId(teamId: String) { + teamsRepo.deleteTeamWithId(teamId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + refreshTeams() + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } + + fun addSpaceFromTeam(spaceTitle: String, teamId: String){ + teamsRepo.addSpace(spaceTitle, teamId).observeOn(AndroidSchedulers.mainThread()).subscribe ({ + refreshTeams() + }, {}).autoDispose() + } + + private fun refreshTeams() { + getTeamsList(0) + } + + fun createMembershipWithEmailId(spaceId: String, emailId: String, isModerator: Boolean) { + membershipRepo.createMembershipWithEmail(spaceId, emailId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } + + fun createMembershipWithId(spaceId: String, personId: String, isModerator: Boolean) { + membershipRepo.createMembershipWithId(spaceId, personId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _createMemberData.postValue(membership) + }, { error -> _teamError.postValue(error.message) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt new file mode 100644 index 0000000..8c4fd42 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailActivity.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityTeamDetailBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + +class TeamDetailActivity : BaseActivity() { + lateinit var binding: ActivityTeamDetailBinding + + private val teamDetailViewModel : TeamDetailViewModel by inject() + private lateinit var teamId: String + + companion object { + fun getIntent(context: Context, teamId: String): Intent { + val intent = Intent(context, TeamDetailActivity::class.java) + intent.putExtra(Constants.Intent.TEAM_ID, teamId) + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + teamId = intent.getStringExtra(Constants.Intent.TEAM_ID) ?: "" + + DataBindingUtil.setContentView(this, R.layout.activity_team_detail) + .also { binding = it } + .apply { + binding.progressLayout.visibility = View.VISIBLE + + teamDetailViewModel.team.observe(this@TeamDetailActivity, Observer { model -> + model?.let { + progressLayout.visibility = View.GONE + binding.team = it + } + }) + } + } + + override fun onResume() { + super.onResume() + teamDetailViewModel.getTeamById(teamId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt new file mode 100644 index 0000000..cf376c3 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/detail/TeamDetailViewModel.kt @@ -0,0 +1,19 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamModel +import com.ciscowebex.androidsdk.kitchensink.messaging.teams.TeamsRepository +import io.reactivex.android.schedulers.AndroidSchedulers + +class TeamDetailViewModel(private val teamsRepo: TeamsRepository) : BaseViewModel() { + private val _team = MutableLiveData() + val team : LiveData = _team + + fun getTeamById(teamId: String){ + teamsRepo.fetchTeamById(teamId).observeOn(AndroidSchedulers.mainThread()).subscribe({ teamModel -> + _team.postValue((teamModel)) + }, { _team.postValue(null)}).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt new file mode 100644 index 0000000..4e889a9 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActionBottomSheetFragment.kt @@ -0,0 +1,45 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetTeamMemberOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class TeamMembershipActionBottomSheetFragment(val membershipDetailsClickListener: (String) -> Unit, + val deleteMembershipClickListener: (String) -> Unit, + val membershipSetModeratorClickListener: (String) -> Unit, + val membershipRemoveModeratorClickListener: (String) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetTeamMemberOptionsBinding + var teamMembershipId: String = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetTeamMemberOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + getMembershipDetails.setOnClickListener { + dismiss() + membershipDetailsClickListener(teamMembershipId) + } + + setMembershipModerator.setOnClickListener { + dismiss() + membershipSetModeratorClickListener(teamMembershipId) + } + + removeMembershipModerator.setOnClickListener { + dismiss() + membershipRemoveModeratorClickListener(teamMembershipId) + } + + deleteMembership.setOnClickListener { + dismiss() + deleteMembershipClickListener(teamMembershipId) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt new file mode 100644 index 0000000..a46d778 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipActivity.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants + +class TeamMembershipActivity : BaseActivity() { + + companion object { + fun getIntent(context: Context, teamId: String): Intent { + val intent = Intent(context, TeamMembershipActivity::class.java) + intent.putExtra(Constants.Intent.TEAM_ID, teamId) + return intent + } + } + + lateinit var binding: ActivityMembershipBinding + + private lateinit var teamId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + teamId = intent.getStringExtra(Constants.Intent.TEAM_ID) ?: "" + + + DataBindingUtil.setContentView(this, R.layout.activity_membership) + .apply { + val fragmentManager = supportFragmentManager + val fragmentTransaction = fragmentManager.beginTransaction() + + val fragment = TeamMembershipFragment.newInstance(teamId) + fragmentTransaction.add(R.id.membershipFragment, fragment) + fragmentTransaction.commit() + } + } +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt new file mode 100644 index 0000000..c759bad --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipFragment.kt @@ -0,0 +1,171 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogTeamMembershipDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentMembershipBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemTeamMembershipClientBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import org.koin.android.ext.android.inject + +class TeamMembershipFragment : Fragment() { + + lateinit var binding: FragmentMembershipBinding + + private val membershipViewModel: TeamMembershipViewModel by inject() + private var teamId: String? = null + + companion object { + fun newInstance(teamId: String): TeamMembershipFragment { + val args = Bundle() + args.putString(Constants.Bundle.TEAM_ID, teamId) + + val fragment = TeamMembershipFragment() + fragment.arguments = args + + return fragment + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + teamId = arguments?.getString(Constants.Bundle.TEAM_ID) + membershipViewModel.teamId = teamId + return FragmentMembershipBinding.inflate(inflater, container, false) + .also { binding = it } + .apply { + val teamMembershipActionBottomSheet = TeamMembershipActionBottomSheetFragment({ teamMembershipId -> membershipViewModel.getTeamMembership(teamMembershipId) }, + { teamMembershipId -> + showDialogWithMessage(requireContext(), getString(R.string.delete_membership), getString(R.string.confirm_delete_membership_action), + onPositiveButtonClick = { dialog, _ -> + dialog.dismiss() + membershipViewModel.deleteMembership(teamMembershipId, resources.getInteger(R.integer.membership_list_size)) + }, + onNegativeButtonClick = { dialog, _ -> + dialog.dismiss() + }) + }, + + { teamMembershipId -> + membershipViewModel.updateMembership(teamMembershipId, true) + }, + { teamMembershipId -> + membershipViewModel.updateMembership(teamMembershipId, false) + } + ) + + val membershipClientAdapter = TeamMembershipClientAdapter(teamMembershipActionBottomSheet, requireActivity().supportFragmentManager) + membershipsRecyclerView.adapter = membershipClientAdapter + membershipsRecyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + + membershipViewModel.memberships.observe(viewLifecycleOwner, Observer { list -> + list?.let { + binding.progressLayout.visibility = View.GONE + membershipClientAdapter.memberships.clear() + membershipClientAdapter.memberships.addAll(it) + membershipClientAdapter.notifyDataSetChanged() + } + }) + + membershipViewModel.membershipDetails.observe(viewLifecycleOwner, Observer { model -> + model?.let { + displayMembershipDetails(it) + } + }) + + membershipViewModel.membershipError.observe(viewLifecycleOwner, Observer { error -> + error?.let { + showErrorDialog(it) + } + }) + + }.root + } + + private fun displayMembershipDetails(teamMembershipDetails: TeamMembershipModel?) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.members_details) + + DialogTeamMembershipDetailsBinding.inflate(layoutInflater) + .apply { + membership = teamMembershipDetails + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + + builder.show() + } + } + + private fun showErrorDialog(errorMessage: String) { + val builder: AlertDialog.Builder = AlertDialog.Builder(requireContext()) + + builder.setTitle(R.string.error_occurred) + builder.setMessage(errorMessage) + + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } + builder.show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + getTeamMembers() + } + + private fun getTeamMembers() { + binding.progressLayout.visibility = View.VISIBLE + val maxMemberships = resources.getInteger(R.integer.membership_list_size) + membershipViewModel.getTeamMembersIn(maxMemberships) + } +} + +class TeamMembershipClientAdapter(private val teamMembershipActionBottomSheet: TeamMembershipActionBottomSheetFragment, + private val supportFragmentManager: FragmentManager) : RecyclerView.Adapter() { + var memberships: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TeamMembershipClientViewHolder { + return TeamMembershipClientViewHolder(teamMembershipActionBottomSheet, ListItemTeamMembershipClientBinding.inflate(LayoutInflater.from(parent.context), parent, false), + supportFragmentManager) + } + + override fun getItemCount(): Int = memberships.size + + override fun onBindViewHolder(holder: TeamMembershipClientViewHolder, position: Int) { + holder.bind(memberships[position]) + } + +} + +class TeamMembershipClientViewHolder(private val teamMembershipActionBottomSheet: TeamMembershipActionBottomSheetFragment, + private val binding: ListItemTeamMembershipClientBinding, + supportFragmentManager: FragmentManager) : RecyclerView.ViewHolder(binding.root) { + var membership: TeamMembershipModel? = null + + init { + binding.root.setOnLongClickListener { _ -> + membership?.let { + teamMembershipActionBottomSheet.teamMembershipId = it.teamMembershipId + teamMembershipActionBottomSheet.show(supportFragmentManager, "Team Membership Options") + } + true + } + } + + fun bind(membership: TeamMembershipModel) { + this.membership = membership + binding.membership = membership + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt new file mode 100644 index 0000000..7f6aad1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipModel.kt @@ -0,0 +1,41 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.team.TeamMembership +import java.util.* + +data class TeamMembershipModel(val teamMembershipId: String, val personId: String, val personEmail: String, + val personDisplayName: String, val isModerator: Boolean, val created: Date, + val personOrgId: String) { + + val createdDateTimeString: String = created.toString() + val isModeratorString: String = isModerator.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SpaceModel + + return teamMembershipId == other.id + } + + override fun hashCode(): Int { + var result = teamMembershipId.hashCode() + result = 31 * result + personId.hashCode() + result = 31 * result + personEmail.hashCode() + result = 31 * result + personDisplayName.hashCode() + result = 31 * result + isModerator.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + personOrgId.hashCode() + return result + } + + companion object { + fun convertToMembershipModel(membership: TeamMembership?): TeamMembershipModel { + return TeamMembershipModel(membership?.id.orEmpty(), membership?.personId.orEmpty(), membership?.personEmail.orEmpty(), + membership?.personDisplayName.orEmpty(), membership?.isModerator ?: false, + membership?.created ?: Date(), membership?.personOrgId.orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt new file mode 100644 index 0000000..91a3d49 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipRepository.kt @@ -0,0 +1,85 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import android.util.Log +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class TeamMembershipRepository(private val webex: Webex) { + fun getTeamMemberships(teamId: String?, max: Int): Observable> { + return Single.create> { emitter -> + webex.teamMembershipClient.list(teamId, max, CompletionHandler { result -> + Log.d(TeamMembershipRepository::class.java.name, "result: " + result.data) + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { + TeamMembershipModel.convertToMembershipModel(it) + } ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getTeamMembership(teamMembershipId: String): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.get(teamMembershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(teamMembershipId: String): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.delete(teamMembershipId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun updateMembership(teamMembershipId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.update(teamMembershipId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithId(teamId: String, personId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.create(teamId, personId, null, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun createMembershipWithEmail(teamId: String, emailId: String, isModerator: Boolean): Observable { + return Single.create { emitter -> + webex.teamMembershipClient.create(teamId, null, emailId, isModerator, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(TeamMembershipModel.convertToMembershipModel(result.data)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt new file mode 100644 index 0000000..166d5c4 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/messaging/teams/membership/TeamMembershipViewModel.kt @@ -0,0 +1,44 @@ +package com.ciscowebex.androidsdk.kitchensink.messaging.teams.membership + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers + +class TeamMembershipViewModel(private val membershipRepo: TeamMembershipRepository) : BaseViewModel() { + var teamId: String? = null + private val _memberships = MutableLiveData>() + val memberships: LiveData> = _memberships + + private val _membershipDetail = MutableLiveData() + val membershipDetails: LiveData = _membershipDetail + + private val _membershipError = MutableLiveData() + val membershipError: LiveData = _membershipError + + fun getTeamMembersIn(max: Int) { + membershipRepo.getTeamMemberships(teamId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ memberships -> + _memberships.postValue(memberships) + }, { _memberships.postValue(emptyList()) }).autoDispose() + } + + fun getTeamMembership(teamMembershipId: String){ + membershipRepo.getTeamMembership(teamMembershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, { error -> _membershipError.postValue(error.message)}).autoDispose() + } + + fun deleteMembership(teamMembershipId: String, max: Int) { + membershipRepo.delete(teamMembershipId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + // refresh list + getTeamMembersIn(max) + }, {error -> _membershipError.postValue(error.message)}).autoDispose() + } + + fun updateMembership(teamMembershipId: String, isModerator: Boolean) { + membershipRepo.updateMembership(teamMembershipId, isModerator).observeOn(AndroidSchedulers.mainThread()).subscribe({ membership -> + _membershipDetail.postValue(membership) + }, {error -> _membershipError.postValue(error.message)}).autoDispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt new file mode 100644 index 0000000..b618b0d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleActionBottomSheetFragment.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetPeopleOptionsBinding +import com.ciscowebex.androidsdk.utils.EmailAddress +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class PeopleActionBottomSheetFragment(val postToPersonID: (String?, String?, PersonModel) -> Unit, + val postToPersonEmail: (String?, EmailAddress?, PersonModel) -> Unit, + val fetchPersonByID: (String) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetPeopleOptionsBinding + lateinit var model: PersonModel + lateinit var personId: String + lateinit var email: EmailAddress + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetPeopleOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + postMessageByID.setOnClickListener { + dismiss() + postToPersonID(personId, null, model) + } + + postMessageByEmail.setOnClickListener { + dismiss() + postToPersonEmail(null, email, model) + } + + fetchPersonByID.setOnClickListener { + dismiss() + fetchPersonByID(personId) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt new file mode 100644 index 0000000..72ecfd1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PeopleFragment.kt @@ -0,0 +1,128 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentPersonBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemPersonBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.composer.MessageComposerActivity +import com.ciscowebex.androidsdk.kitchensink.utils.extensions.isValidEmail +import com.ciscowebex.androidsdk.utils.EmailAddress +import org.koin.android.ext.android.inject + + +class PeopleFragment : Fragment() { + private lateinit var binding: FragmentPersonBinding + private lateinit var peopleClientAdapter: PeopleClientAdapter + + private val personViewModel: PersonViewModel by inject() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentPersonBinding.inflate(inflater, container, false).also { binding = it }.apply { + val optionsDialogFragment = PeopleActionBottomSheetFragment( + { personId, _, model -> showPostMessageDialog(personId, null, model) }, + { _, email, model -> showPostMessageDialog(null, email, model) }, + { personId -> fetchDetailsById(personId) }) + + peopleClientAdapter = PeopleClientAdapter(optionsDialogFragment, requireActivity().supportFragmentManager) + + recyclerView.adapter = peopleClientAdapter + lifecycleOwner = this@PeopleFragment + + personViewModel.personList.observe(this@PeopleFragment.viewLifecycleOwner, Observer { list -> + list?.let { + peopleClientAdapter.persons.clear() + peopleClientAdapter.persons.addAll(it) + peopleClientAdapter.notifyDataSetChanged() + } + }) + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (newText.isValidEmail()) { + personViewModel.getPeopleList(newText, null, null, null, resources.getInteger(R.integer.person_list_size)) + } else { + personViewModel.getPeopleList(null, newText, null, null, resources.getInteger(R.integer.person_list_size)) + } + return false + } + + }) + }.root + } + + override fun onResume() { + super.onResume() + personViewModel.getPeopleList(null, null, null, null, resources.getInteger(R.integer.person_list_size)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + } + + private fun fetchDetailsById(personId: String) { + PersonDialogFragment.newInstance(personId).show(childFragmentManager, getString(R.string.person_detail)) + } + + private fun showPostMessageDialog(id: String?, email: EmailAddress?, model: PersonModel) { + id?.let { + val composerType = MessageComposerActivity.Companion.ComposerType.POST_PERSON_ID + ContextCompat.startActivity(requireActivity(), + MessageComposerActivity.getIntent(requireActivity(), composerType, it, null), null) + } ?: run { + email?.let { + val composerType = MessageComposerActivity.Companion.ComposerType.POST_PERSON_EMAIL + ContextCompat.startActivity(requireActivity(), + MessageComposerActivity.getIntent(requireActivity(), composerType, it.toString(), null), null) + } + } + } +} + +class PeopleClientAdapter(private val optionsDialogFragment: PeopleActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.Adapter() { + var persons: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PeopleClientViewHolder { + return PeopleClientViewHolder(ListItemPersonBinding.inflate(LayoutInflater.from(parent.context), parent, false), optionsDialogFragment, fragmentManager) + } + + override fun getItemCount(): Int = persons.size + + override fun onBindViewHolder(holder: PeopleClientViewHolder, position: Int) { + holder.bind(persons[position]) + } +} + +class PeopleClientViewHolder(private val binding: ListItemPersonBinding, private val optionsDialogFragment: PeopleActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.ViewHolder(binding.root) { + fun bind(person: PersonModel) { + binding.person = person + + binding.personClientLayout.setOnLongClickListener { view -> + optionsDialogFragment.personId = person.personId + if (person.emails.isEmpty()) { + optionsDialogFragment.email = EmailAddress.fromString("") + } else { + optionsDialogFragment.email = EmailAddress.fromString(person.emails[0]) + } + optionsDialogFragment.model = person + + optionsDialogFragment.show(fragmentManager, "People Options") + + true + } + + binding.executePendingBindings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt new file mode 100644 index 0000000..4aa74c8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonDialogFragment.kt @@ -0,0 +1,65 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentDialogPersonBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.android.ext.android.inject + +class PersonDialogFragment : DialogFragment() { + + companion object { + fun newInstance(personId: String) : PersonDialogFragment { + val args = Bundle() + args.putString(Constants.Bundle.PERSON_ID, personId) + + val fragment = PersonDialogFragment() + fragment.arguments = args + + return fragment + } + } + + private val personViewModel : PersonViewModel by inject() + private lateinit var personId : String + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + personId = arguments?.getString(Constants.Bundle.PERSON_ID) ?: "" + + return FragmentDialogPersonBinding.inflate(inflater, container, false) + .apply { + progressLayout.visibility = View.VISIBLE + + personViewModel.person.observe(this@PersonDialogFragment, Observer { model -> + model?.let { + progressLayout.visibility = View.GONE + person = it + } + }) + + dialogOk.setOnClickListener { dismiss() } + }.root + } + + override fun onResume() { + super.onResume() + if(personId.isEmpty()) { + personViewModel.getMe() + } else { + personViewModel.getPersonDetail(personId) + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt new file mode 100644 index 0000000..c6cb751 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModel.kt @@ -0,0 +1,50 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.os.Parcelable +import com.ciscowebex.androidsdk.people.Person +import kotlinx.android.parcel.Parcelize +import java.util.* + +@Parcelize +data class PersonModel(val personId: String, val emails: List, val displayName: String, + val nickName: String, val firstName: String, val lastName: String, + val avatar: String, val orgId: String, val created: Date, + val lastActivity: String, val status: String, val type: String) : Parcelable { + + val createdString: String = created.toString() + val emailList = emails.joinToString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PersonModel + + return personId == other.personId + } + + override fun hashCode(): Int { + var result = personId.hashCode() + result = 31 * result + emails.hashCode() + result = 31 * result + displayName.hashCode() + result = 31 * result + nickName.hashCode() + result = 31 * result + firstName.hashCode() + result = 31 * result + lastName.hashCode() + result = 31 * result + avatar.hashCode() + result = 31 * result + orgId.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + lastActivity.hashCode() + result = 31 * result + status.hashCode() + result = 31 * result + type.hashCode() + return result + } + + companion object { + fun convertToPersonModel(person: Person?): PersonModel { + return PersonModel(person?.id.orEmpty(), person?.emails.orEmpty(), person?.displayName.orEmpty(), + person?.nickName.orEmpty(), person?.firstName.orEmpty(), person?.lastName.orEmpty(), + person?.avatar.orEmpty(), person?.orgId.orEmpty(), person?.created ?: Date(), + person?.lastActivity.orEmpty(), person?.status.orEmpty(), person?.type.orEmpty()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt new file mode 100644 index 0000000..f7d96fc --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonModule.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val personModule = module { + viewModel { PersonViewModel(get()) } + + single { PersonRepository(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt new file mode 100644 index 0000000..ce2b8a8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonRepository.kt @@ -0,0 +1,94 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import android.util.Log +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.CompletionHandler +import io.reactivex.Observable +import io.reactivex.Single + +class PersonRepository(private val webex: Webex) { + + fun getMe(): Observable { + return Single.create { emitter -> + webex.people.getMe(CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getPersonDetail(personId: String): Observable { + return Single.create { emitter -> + webex.people.get(personId, CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun getPeopleList(email: String?, displayName: String?, id: String?, orgId: String?, max: Int): Observable> { + return Single.create> { emitter -> + webex.people.list(email, displayName, id, orgId, max, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data?.map { PersonModel.convertToPersonModel(it) }.orEmpty()) + Log.d("CRUD_TEST", "Listed persons successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } + + fun createPerson(email: String, displayName: String?, firstName: String?, lastName: String?, avatar: String?, orgId: String?, roles: String?, licenses: String?): Observable { + return Single.create { emitter -> + webex.people.create(email, displayName, firstName, lastName, avatar, orgId, roles, licenses, CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + Log.d("CRUD_TEST", "Created person successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } + + fun updatePerson(personId: String, email: String?, displayName: String?, firstName: String?, lastName: String?, avatar: String?, orgId: String?, roles: String?, licenses: String?): Observable { + return Single.create { emitter -> + webex.people.update(personId, email, displayName, firstName, lastName, avatar, orgId, roles, licenses, CompletionHandler { result -> + if (result.isSuccessful) { + val person = result.data + emitter.onSuccess(PersonModel.convertToPersonModel(person)) + Log.d("CRUD_TEST", "Updated person details successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } + + fun deletePerson(personId: String): Observable { + return Single.create { emitter -> + webex.people.delete(personId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + Log.d("CRUD_TEST", "Deleted person successfully"); + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + Log.d("CRUD_TEST", result.error?.errorMessage ?: ""); + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt new file mode 100644 index 0000000..27b931a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/person/PersonViewModel.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink.person + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import io.reactivex.android.schedulers.AndroidSchedulers + +class PersonViewModel(private val personRepo: PersonRepository) : BaseViewModel() { + private val tag = "PersonViewModel" + + private val _person = MutableLiveData() + val person: LiveData = _person + + private val _personList = MutableLiveData>() + val personList: LiveData> = _personList + + fun getMe() { + personRepo.getMe().observeOn(AndroidSchedulers.mainThread()).subscribe({ + _person.postValue(it) + }, { _person.postValue(null) }).autoDispose() + } + + fun getPersonDetail(personId: String) { + personRepo.getPersonDetail(personId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _person.postValue(it) + }, { _person.postValue(null) }).autoDispose() + } + + fun getPeopleList(email: String?, displayName: String?, id: String?, orgId: String?, max: Int) { + personRepo.getPeopleList(email, displayName, id, orgId, max).observeOn(AndroidSchedulers.mainThread()).subscribe({ personModels -> + _personList.postValue(personModels) + }, { _personList.postValue(emptyList()) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt new file mode 100644 index 0000000..7ac937f --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchActivity.kt @@ -0,0 +1,73 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.calling.DialFragment +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySearchBinding +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.HorizontalFlipTransformation +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy +import org.koin.android.viewmodel.ext.android.viewModel + +class SearchActivity : BaseActivity() { + lateinit var binding: ActivitySearchBinding + + private val searchViewModel: SearchViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_search) + .also { binding = it } + .apply { + viewPager.adapter = ViewPagerFragmentAdapter(this@SearchActivity, searchViewModel.titles) + viewPager.setPageTransformer(HorizontalFlipTransformation()) + TabLayoutMediator(tabLayout, viewPager, + TabConfigurationStrategy { tab: TabLayout.Tab, position: Int -> + tab.text = searchViewModel.titles[position] + } + ).attach() + } + } + + private class ViewPagerFragmentAdapter(fragmentActivity: FragmentActivity, val titles: List) : + FragmentStateAdapter(fragmentActivity) { + override fun createFragment(position: Int): Fragment { + when (position) { + 0 -> return DialFragment() + 1 -> { + val bundle = Bundle() + bundle.putString(Constants.Bundle.KEY_TASK_TYPE, SearchCommonFragment.Companion.TaskType.TaskSearchSpace) + val searchFragment = SearchCommonFragment() + searchFragment.arguments = bundle + return searchFragment + } + 2 -> { + val bundle = Bundle() + bundle.putString(Constants.Bundle.KEY_TASK_TYPE, SearchCommonFragment.Companion.TaskType.TaskCallHistory) + val callHistoryFragment = SearchCommonFragment() + callHistoryFragment.arguments = bundle + return callHistoryFragment + } + 3 -> { + val bundle = Bundle() + bundle.putString(Constants.Bundle.KEY_TASK_TYPE, SearchCommonFragment.Companion.TaskType.TaskListSpaces) + val spaceListFragment = SearchCommonFragment() + spaceListFragment.arguments = bundle + return spaceListFragment + } + } + return SearchCommonFragment() + } + + override fun getItemCount(): Int { + return titles.size + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt new file mode 100644 index 0000000..2be9af5 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchCommonFragment.kt @@ -0,0 +1,234 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.calling.CallActivity +import com.ciscowebex.androidsdk.kitchensink.databinding.CommonFragmentItemListBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentCommonBinding +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.space.Space +import kotlinx.android.synthetic.main.fragment_common.* +import org.koin.android.ext.android.inject + + +class SearchCommonFragment : Fragment() { + private val searchViewModel: SearchViewModel by inject() + private var adapter: CustomAdapter = CustomAdapter() + private val itemModelList = mutableListOf() + lateinit var taskType: String + + companion object { + object TaskType { + const val TaskSearchSpace = "SearchSpace" + const val TaskCallHistory = "CallHistory" + const val TaskListSpaces = "ListSpaces" + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return FragmentCommonBinding.inflate(inflater, container, false).apply { + lifecycleOwner = this@SearchCommonFragment + + recyclerView.itemAnimator = DefaultItemAnimator() + + recyclerView.adapter = adapter + + taskType = arguments?.getString(Constants.Bundle.KEY_TASK_TYPE) + ?: TaskType.TaskListSpaces + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + progress_bar.visibility = View.VISIBLE + searchViewModel.search(newText) + return false + } + + }) + + setUpViewModelObservers() + + }.root + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + updateSearchInputViewVisibility() + progress_bar.visibility = View.VISIBLE + } + + override fun onResume() { + super.onResume() + searchViewModel.loadData(taskType, resources.getInteger(R.integer.space_list_size)) + } + + private fun setUpViewModelObservers() { + // TODO: Put common code inside a function + searchViewModel.spaces.observe(viewLifecycleOwner, Observer { list -> + list?.let { + if (taskType == TaskType.TaskCallHistory) it.sortedBy { it.created } else it.sortedByDescending { it.lastActivity } + + if (it.isEmpty()) { + updateEmptyListUI(true) + } else { + updateEmptyListUI(false) + itemModelList.clear() + for (i in it.indices) { + val id = it[i].id + val item = itemModelList.find { listItem -> listItem.callerId == id } + if (item == null) { + val itemModel = ItemModel() + itemModel.name = it[i].title + itemModel.image = R.drawable.ic_call + itemModel.callerId = id + itemModel.ongoing = searchViewModel.isSpaceCallStarted() && searchViewModel.spaceCallId() == id + //add in array list + itemModelList.add(itemModel) + } + } + adapter.itemList = itemModelList + adapter.notifyDataSetChanged() + } + } + }) + + searchViewModel.searchResult.observe(viewLifecycleOwner, Observer { list -> + list?.let { + if (it.isEmpty()) { + updateEmptyListUI(true) + } else { + updateEmptyListUI(false) + itemModelList.clear() + for (i in it.indices) { + val itemModel = ItemModel() + val space = it[i] + itemModel.name = space.title.orEmpty() + itemModel.image = R.drawable.ic_call + itemModel.callerId = space.id.orEmpty() + itemModelList.add(itemModel) + } + adapter.itemList = itemModelList + adapter.notifyDataSetChanged() + } + } + }) + + searchViewModel.getSpaceEvent()?.observe(viewLifecycleOwner, Observer { + when (it.first) { + WebexRepository.SpaceEvent.CallStarted -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { id -> + updateSpaceCallStatus(id, true) + } + } + } + WebexRepository.SpaceEvent.CallEnded -> { + if (it.second is String?) { + val spaceId = it.second as String? + spaceId?.let { id -> + updateSpaceCallStatus(id, false) + } + } + } + else -> {} + } + }) + } + + private fun updateSpaceCallStatus(spaceId: String, callStarted: Boolean) { + val index = adapter.getPositionById(spaceId) + if (index != -1) { + val model = adapter.itemList[index] + model.ongoing = callStarted + adapter.notifyItemChanged(index) + } + } + + private fun updateEmptyListUI(listEmpty: Boolean) { + progress_bar.visibility = View.GONE + if (listEmpty) { + tv_empty_data.visibility = View.VISIBLE + recycler_view.visibility = View.GONE + } else { + tv_empty_data.visibility = View.GONE + recycler_view.visibility = View.VISIBLE + } + } + + private fun updateSearchInputViewVisibility() { + when (taskType) { + TaskType.TaskSearchSpace -> { + search_view.visibility = View.VISIBLE + } + else -> { + search_view.visibility = View.GONE + } + } + } + + class ItemModel { + var image = 0 + lateinit var name: String + lateinit var callerId: String + var ongoing = false + } + + class CustomAdapter() : + RecyclerView.Adapter() { + var itemList: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, i: Int): ViewHolder { + return ViewHolder(CommonFragmentItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + viewHolder.bind(itemList[position]) + } + + override fun getItemCount(): Int { + return itemList.size + } + + inner class ViewHolder(val binding: CommonFragmentItemListBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(itemModel: ItemModel) { + binding.listItem = itemModel + binding.image.setOnClickListener { + it.context.startActivity(CallActivity.getOutgoingIntent(it.context, itemModel.callerId)) + } + + if (itemModel.ongoing) { + binding.ongoing.visibility = View.VISIBLE + } else { + binding.ongoing.visibility = View.GONE + } + binding.executePendingBindings() + } + } + + fun getPositionById(spaceId: String): Int { + return itemList.indexOfFirst { it.callerId == spaceId } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt new file mode 100644 index 0000000..8848c5a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchModule.kt @@ -0,0 +1,11 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val searchModule = module { + viewModel { + SearchViewModel(get(), get(), get()) + } + single { SearchRepository(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt new file mode 100644 index 0000000..de90fe0 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchRepository.kt @@ -0,0 +1,35 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.space.Space +import io.reactivex.Observable +import io.reactivex.Single + +class SearchRepository(private val webex: Webex) { + + fun getCallHistory(): Observable?> { + val space = webex.phone.getCallHistory() + + return Observable.just( + space?.map { + SpaceModel(it.id.orEmpty(), it.title.orEmpty(), it.type, + it.isLocked, it.lastActivity, it.created, + it.teamId.orEmpty(), it.sipAddress.orEmpty()) + } ?: emptyList() + ) + } + + fun search(query: String): Observable> { + return Single.create> { emitter -> + webex.spaces.filter(query, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(result.data ?: emptyList()) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt new file mode 100644 index 0000000..4016a1a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/search/SearchViewModel.kt @@ -0,0 +1,78 @@ +package com.ciscowebex.androidsdk.kitchensink.search + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpaceModel +import com.ciscowebex.androidsdk.kitchensink.messaging.spaces.SpacesRepository +import com.ciscowebex.androidsdk.space.Space +import io.reactivex.android.schedulers.AndroidSchedulers + +class SearchViewModel(private val searchRepo: SearchRepository, private val spacesRepo: SpacesRepository, private val webexRepo: WebexRepository) : BaseViewModel() { + private val tag = "SearchViewModel" + private val _spaces = MutableLiveData>() + val spaces: LiveData> = _spaces + + private val _searchResult = MutableLiveData>() + val searchResult: LiveData> = _searchResult + + private val _spaceEventLiveData = MutableLiveData>() + + val titles = + listOf("Call", "Search", "History", "Spaces") + + var name = listOf( + "Bharath Balan", + "Adam Ranganathan", + "Rohit Sharma", + "Manoj Nuthakki", + "Linda Nixon", + "Akshay Agarwal", + "Lalit Sharma", + "Manu Jain", + "Ankit Batra", + "Jasna Ibrahim" + ) + + init { + webexRepo._spaceEventLiveData = _spaceEventLiveData + } + + fun getSpaceEvent() = webexRepo._spaceEventLiveData + + fun isSpaceCallStarted() = webexRepo.isSpaceCallStarted + fun spaceCallId() = webexRepo.spaceCallId + + fun loadData(taskType: String, maxSpaceCount: Int) { + when (taskType) { + SearchCommonFragment.Companion.TaskType.TaskCallHistory -> { + searchRepo.getCallHistory().observeOn(AndroidSchedulers.mainThread()).subscribe({ + Log.d(tag, "Size of $taskType is ${it?.size?.or(0)}") + _spaces.postValue(it) + }, { + _spaces.postValue(emptyList()) + }).autoDispose() + } + SearchCommonFragment.Companion.TaskType.TaskSearchSpace -> { + search("") + } + SearchCommonFragment.Companion.TaskType.TaskListSpaces -> { + spacesRepo.fetchSpacesList(null, maxSpaceCount).observeOn(AndroidSchedulers.mainThread()).subscribe({ spacesList -> + _spaces.postValue(spacesList) + }, { _spaces.postValue(emptyList()) }).autoDispose() + } + } + } + + fun search(query: String?) { + query?.let { searchQuery -> + searchRepo.search(searchQuery).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _searchResult.postValue(it) + }, { + _searchResult.postValue(emptyList()) + }).autoDispose() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt new file mode 100644 index 0000000..7e09918 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/setup/SetupActivity.kt @@ -0,0 +1,196 @@ +package com.ciscowebex.androidsdk.kitchensink.setup + +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.AdapterView +import androidx.databinding.DataBindingUtil +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.WebexRepository +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivitySetupBinding +import com.ciscowebex.androidsdk.phone.Phone + +class SetupActivity: BaseActivity() { + + enum class CameraCap { + Front, + Back, + Close + } + + lateinit var binding: ActivitySetupBinding + private var cameraCap: CameraCap = CameraCap.Close + private lateinit var callCap: WebexRepository.CallCap + private lateinit var streamMode: Phone.VideoStreamMode + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tag = "SetupActivity" + + DataBindingUtil.setContentView(this, R.layout.activity_setup) + .also { binding = it } + .apply { + cameraCap = getDefaultCamera() + + callCap = webexViewModel.callCapability + + when (callCap) { + WebexRepository.CallCap.Audio_Only -> { + audioCallOnly.isChecked = true + } + WebexRepository.CallCap.Audio_Video -> { + audioVideoCall.isChecked = true + } + } + + when (cameraCap) { + CameraCap.Front -> { + frontCamera.isChecked = true + setAndStartFrontCamera() + } + CameraCap.Back -> { + backCamera.isChecked = true + setAndStartBackCamera() + } + CameraCap.Close -> { + closePreview() + } + } + + streamMode = webexViewModel.streamMode + + when (streamMode) { + Phone.VideoStreamMode.COMPOSITED -> { + composited.isChecked = true + } + Phone.VideoStreamMode.AUXILIARY -> { + multiStream.isChecked = true + } + } + + cameraRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.closePreview -> { + closePreview() + } + R.id.frontCamera -> { + setAndStartFrontCamera() + } + R.id.backCamera -> { + setAndStartBackCamera() + } + } + } + + callCapabilityRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.audioCallOnly -> { + webexViewModel.callCapability = WebexRepository.CallCap.Audio_Only + } + R.id.audioVideoCall -> { + webexViewModel.callCapability = WebexRepository.CallCap.Audio_Video + } + } + } + + enableBgStreamToggle.isChecked = webexViewModel.enableBgStreamtoggle + + enableBgStreamToggle.setOnCheckedChangeListener { _, checked -> + webexViewModel.enableBgStreamtoggle = checked + webexViewModel.enableBackgroundStream(checked) + } + + streamModeRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.composited -> { + webexViewModel.streamMode = Phone.VideoStreamMode.COMPOSITED + } + R.id.multiStream -> { + webexViewModel.streamMode = Phone.VideoStreamMode.AUXILIARY + } + } + + webexViewModel.setVideoStreamMode(webexViewModel.streamMode) + } + + enableBgConnectionToggle.isChecked = webexViewModel.enableBgConnectiontoggle + + enableBgConnectionToggle.setOnCheckedChangeListener { _, checked -> + webexViewModel.enableBgConnectiontoggle = checked + webexViewModel.enableBackgroundConnection(checked) + } + + enablePhonePermissionToggle.isChecked = webexViewModel.enablePhoneStatePermission + + enablePhonePermissionToggle.setOnCheckedChangeListener { _, checked -> + webexViewModel.enablePhoneStatePermission = checked + webexViewModel.enableAskingReadPhoneStatePermission(checked) + } + + logLevelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onNothingSelected(p0: AdapterView<*>?) { + } + + override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { + webexViewModel.logFilter = resources.getStringArray(R.array.logFilterArray)[position] + webexViewModel.setLogLevel(webexViewModel.logFilter) + Log.d(tag, "selected logLevel ${webexViewModel.logFilter}") + } + } + + logLevelSpinner.setSelection(resources.getStringArray(R.array.logFilterArray).indexOf(webexViewModel.logFilter)) + + switchConsoleLog.setOnCheckedChangeListener { _ , checked -> + webexViewModel.isConsoleLoggerEnabled = checked + webexViewModel.enableConsoleLogger(webexViewModel.isConsoleLoggerEnabled) + Log.d(tag, "enable console logger ${webexViewModel.isConsoleLoggerEnabled}") + } + switchConsoleLog.isChecked = webexViewModel.isConsoleLoggerEnabled + } + } + + private fun getDefaultCamera(): CameraCap { + if (cameraCap == CameraCap.Close) { + return cameraCap + } + + return if (webexViewModel.getDefaultFacingMode() == Phone.FacingMode.USER) { + CameraCap.Front + } else { + CameraCap.Back + } + } + + private fun closePreview() { + stopPreview() + } + + private fun setAndStartFrontCamera() { + webexViewModel.setDefaultFacingMode(Phone.FacingMode.USER) + cameraCap = CameraCap.Front + startPreview() + } + + private fun setAndStartBackCamera() { + webexViewModel.setDefaultFacingMode(Phone.FacingMode.ENVIROMENT) + cameraCap = CameraCap.Back + startPreview() + } + + private fun startPreview() { + binding.preview.visibility = View.VISIBLE + cameraCap = getDefaultCamera() + webexViewModel.startPreview(binding.preview) + } + + private fun stopPreview() { + webexViewModel.stopPreview() + binding.preview.visibility = View.GONE + } + + override fun onDestroy() { + webexViewModel.stopPreview() + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt new file mode 100644 index 0000000..f1515a7 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/AudioManagerUtils.kt @@ -0,0 +1,29 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothHeadset +import android.content.Context +import android.media.AudioManager +import android.util.Log + + +open class AudioManagerUtils(val context: Context) { + private var audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val tag = "AudioManagerUtils" + + val isWiredHeadsetOn: Boolean + get() { + val isWiredHeadsetOn = audioManager.isWiredHeadsetOn + Log.i(tag, "AudioManager.isWiredHeadsetOn = $isWiredHeadsetOn") + return isWiredHeadsetOn + } + + val isBluetoothHeadsetConnected: Boolean + get() { + val mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + val isBtConnected = (mBluetoothAdapter != null && mBluetoothAdapter.isEnabled + && mBluetoothAdapter.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothHeadset.STATE_CONNECTED) + Log.i(tag, "AudioManager.isBluetoothHeadsetConnected = $isBtConnected") + return isBtConnected + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt new file mode 100644 index 0000000..374d979 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Base64Utils.kt @@ -0,0 +1,17 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.util.Base64 +import android.util.Log +import com.ciscowebex.androidsdk.kitchensink.firebase.KitchenSinkFCMService + +object Base64Utils { + const val TAG = "Base64Utils" + + fun decodeString(encodedId: String?): String { + val decodedBytes: ByteArray = Base64.decode(encodedId, Base64.DEFAULT) + val decodedString = String(decodedBytes) + val decodedId = decodedString.substring(decodedString.lastIndexOf("/") + 1) + Log.d(TAG, "decodedString: $decodedString, decodedString: $decodedString, originalRoomId: $decodedId") + return decodedId + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt new file mode 100644 index 0000000..742d1a1 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/BindingAdapters.kt @@ -0,0 +1,10 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.widget.TextView +import androidx.databinding.BindingAdapter +import java.util.* + +@BindingAdapter("dateString") +fun setDateString(view: TextView, dateInLong: Long){ + view.text = Date(dateInLong).toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt new file mode 100644 index 0000000..1ea3845 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/CallObjectStorage.kt @@ -0,0 +1,46 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import com.ciscowebex.androidsdk.phone.Call + +object CallObjectStorage { + private var callObjects: ArrayList = ArrayList() + + fun addCallObject(call: Call) { + synchronized(this) { + val callObj = getCallObject(call.getCallId() ?: "") + if (callObj == null) { + callObjects.add(call) + } + } + } + + fun removeCallObject(callId: String) { + synchronized(this) { + val itr = callObjects.iterator() + while (itr.hasNext()) { + val call = itr.next() + if (call.getCallId() == callId) { + itr.remove() + } + } + } + } + + fun getCallObject(callId: String): Call? { + synchronized(this) { + for (call in callObjects) { + if (call.getCallId() == callId) { + return call + } + } + + return null + } + } + + fun clearStorage() { + synchronized(this) { + callObjects.clear() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt new file mode 100644 index 0000000..b7c36ae --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Constants.kt @@ -0,0 +1,36 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +class Constants { + object Intent { + const val PERSON = "PERSON" + const val OUTGOING_CALL_CALLER_ID = "OUTGOING_CALL_CALLER_ID" + const val CALLING_ACTIVITY_ID = "CALLING_ACTIVITY_ID" + const val TEAM_ID = "teamId" + const val SPACE_ID = "spaceId" + const val COMPOSER_ID = "composerId" + const val COMPOSER_TYPE = "composerType" + const val COMPOSER_REPLY_PARENT_MESSAGE = "composerReplyParentMessage" + const val CALL_ID = "callid" + const val MESSAGE_ID = "MESSAGE_ID" + } + object Bundle { + const val MESSAGE_ID = "messageId" + const val PERSON_ID = "person_id" + const val KEY_TASK_TYPE = "task_type" + const val SPACE_ID = "spaceId" + const val IS_CALLING_ENABLED = "isCallingEnabled" + const val IS_MESSAGING_ENABLED = "isMessagingEnabled" + const val TEAM_ID = "teamId" + const val REMOTE_FILE = "remote_file" + } + object Action { + const val MESSAGE_ACTION = "MESSAGE_ACTION" + const val WEBEX_CALL_ACTION = "WEBEX_CALL_ACTION" + } + object Keys { + const val PushRestEncryptionKey = "PeShVmYq3s6v9yaBwE1H3McQfTjWnZr4" //256 bit AES key, use base64 encoded key to send to cucm endpoint + const val KitchenSinkSharedPref = "KSSharedPref" + const val LoginType = "LoginType" + const val Email = "Email" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt new file mode 100644 index 0000000..42904ef --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/Crypto.kt @@ -0,0 +1,39 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectDecrypter +import com.nimbusds.jose.crypto.DirectEncrypter + +/** + * @param payload : The payload to encrypt, can be any string. + * @param encryptionKey : Symmetric encryption key to ecrypt the payload. Use 256 bit symmetric key for AES256GCM encryption algo + * Dummy key for example usage: "@McQfTjWnZr4u7x!A%D*G-KaNdRgUkXp" + */ +fun encryptPushRESTPayload(payload: String, encryptionKey: String = Constants.Keys.PushRestEncryptionKey): String { + // Create the header + val header = JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM) + + val keyBA = encryptionKey.toByteArray() + // Create the JWE object and encrypt it + val jweObject = JWEObject(header, Payload(payload)) + jweObject.encrypt(DirectEncrypter(keyBA)) + + // Serialise to compact JOSE form... + return jweObject.serialize() +} + +/** + * @param payload : JWE format string (https://tools.ietf.org/html/rfc7516) + * @param decryptionKey : AES 256 bit symmetric key. This is the same encryption key as was used to ecrypt the payload, + * As we use dir algorithm, so both encryption/decryption keys are same here. + */ +fun decryptPushRESTPayload(payload: String, decryptionKey: String = Constants.Keys.PushRestEncryptionKey): String { + val jweObject = JWEObject.parse(payload) + jweObject.decrypt(DirectDecrypter(decryptionKey.toByteArray())) + val decryptedPayload = jweObject.payload + return decryptedPayload.toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt new file mode 100644 index 0000000..2adddcf --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/DialogUtils.kt @@ -0,0 +1,51 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.Context +import android.content.DialogInterface +import android.text.InputType +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import com.ciscowebex.androidsdk.kitchensink.R + + +fun showDialogWithMessage(context: Context, titleResourceId: Int?, message: String, positiveButtonText: Int = android.R.string.ok) { + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + + builder.setTitle(titleResourceId ?: R.string.message) + builder.setMessage(message) + + builder.setPositiveButton(positiveButtonText) { dialog, _ -> + dialog.dismiss() + } + + builder.show() +} + +fun showDialogWithMessage(context: Context, title: String, message: String, positiveButtonText: Int = R.string.yes, cancelable: Boolean = true, + onPositiveButtonClick: (DialogInterface, Int) -> Unit, negativeButtonText: Int = R.string.no, onNegativeButtonClick: (DialogInterface, Int) -> Unit) { + + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setCancelable(cancelable) + .setPositiveButton(positiveButtonText, onPositiveButtonClick) + .setNegativeButton(negativeButtonText, onNegativeButtonClick) + .show() +} + +fun showDialogForInputEmail(context: Context, title: String, positiveButtonText: Int = android.R.string.ok, + onPositiveButtonClick: (DialogInterface, String) -> Unit, negativeButtonText: Int = android.R.string.cancel, + onNegativeButtonClick: (DialogInterface, Int) -> Unit) { + val input = EditText(context) + input.inputType = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + AlertDialog.Builder(context) + .setTitle(title) + .setView(input) + .setPositiveButton(positiveButtonText) { dialogInterface: DialogInterface, i: Int -> + val email = input.text.toString(); + onPositiveButtonClick(dialogInterface, email) + } + .setNegativeButton(negativeButtonText, onNegativeButtonClick) + .show() +} + diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt new file mode 100644 index 0000000..3747c7d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/FileUtils.kt @@ -0,0 +1,127 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import com.ciscowebex.androidsdk.kitchensink.BuildConfig +import java.io.File + + +object FileUtils { + fun getThumbnailFile(context: Context): File { + val dirPath = context.cacheDir.absolutePath + File.separator + "Thumbnail" + File.separator + val dir = File(dirPath) + if (!dir.exists() && !dir.isDirectory) { + dir.mkdirs() + } + Log.d("FileUtils", dir.absolutePath) + return dir + } + + fun getFile(context: Context): File { + val dirPath = context.cacheDir.absolutePath + File.separator + "Files" + File.separator + val dir = File(dirPath) + if (!dir.exists() && !dir.isDirectory) { + dir.mkdirs() + } + Log.d("FileUtils", dir.absolutePath) + return dir + } + + fun getUploadUriPath(context: Context, uri: Uri): String? { + if (DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + return getExternalFilesDirPath(context)?.let { it + "/" + split[1] } + } + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri: Uri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).toTypedArray() + val type = split[0] + var contentUri: Uri? = null + if ("image" == type) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else if ("video" == type) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else if ("audio" == type) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } + return null + } + + private fun getExternalFilesDirPath(context: Context): String? { + val dir = context.getExternalFilesDir(null) + dir?.let { + val extraPortion = "/Android/data/" + BuildConfig.APPLICATION_ID + File.separator.toString() + "files" + return it.absolutePath.replace(extraPortion, "", true) + } + + return null + } + + private fun getDataColumn(context: Context, uri: Uri?, selection: String?, selectionArgs: Array?): String? { + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf(column) + try { + uri?.let { + cursor = context.contentResolver.query(it, projection, selection, selectionArgs, null) + cursor?.let { cur -> + if (cur.moveToFirst()) { + val index: Int = cur.getColumnIndexOrThrow(column) + return cur.getString(index) + } + } + } + } finally { + cursor?.close() + } + return null + } + + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + private fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.authority + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt new file mode 100644 index 0000000..fe0f6e8 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/HorizontalFlipTransformation.kt @@ -0,0 +1,29 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import kotlin.math.abs + + +class HorizontalFlipTransformation : ViewPager2.PageTransformer { + override fun transformPage(page: View, position: Float) { + page.translationX = -position * page.width + page.cameraDistance = 12000F + if (position < 0.5 && position > -0.5) { + page.visibility = View.VISIBLE + } else { + page.visibility = View.INVISIBLE + } + if (position < -1) { // [-Infinity,-1) + page.alpha = 0F + } else if (position <= 0) { // [-1,0] + page.alpha = 1F + page.rotationY = 180 * (1 - abs(position) + 1) + } else if (position <= 1) { // (0,1] + page.alpha = 1F + page.rotationY = -180 * (1 - abs(position) + 1) + } else { + page.alpha = 0F + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt new file mode 100644 index 0000000..bb7e5cb --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/PermissionsHelper.kt @@ -0,0 +1,67 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +class PermissionsHelper(private val context: Context) { + + fun hasCameraPermission(): Boolean { + return checkSelfPermission(Manifest.permission.CAMERA) + } + + fun hasMicrophonePermission(): Boolean { + return checkSelfPermission(Manifest.permission.RECORD_AUDIO) + } + + fun hasPhoneStatePermission(): Boolean { + return checkSelfPermission(Manifest.permission.READ_PHONE_STATE) + } + + fun hasReadStoragePermission(): Boolean { + return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + private fun checkSelfPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + companion object { + const val PERMISSIONS_CALLING_REQUEST = 0 + const val PERMISSIONS_STORAGE_REQUEST = 1 + + fun permissionsForCalling(): Array { + return arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO, Manifest.permission.READ_PHONE_STATE) + } + + fun permissionForStorage(): Array { + return arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + fun resultForCallingPermissions(permissions: Array, grantResults: IntArray): Boolean { + var result = true + + for (permission in permissions) { + result = result and checkPermissionsResults(permission, permissions, grantResults) + } + + return result + } + + private fun checkPermissionsResults(permissionRequested: String, permissions: Array, grantResults: IntArray): Boolean { + for (index in permissions.indices) { + val permission = permissions[index] + val grantResult = grantResults[index] + + if (permissionRequested == permission) { + return grantResult == PackageManager.PERMISSION_GRANTED + } + } + + return false + } + } +} diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt new file mode 100644 index 0000000..461d61d --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/SharedPrefUtils.kt @@ -0,0 +1,52 @@ +package com.ciscowebex.androidsdk.kitchensink.utils + +import android.content.Context +import com.ciscowebex.androidsdk.kitchensink.auth.LoginActivity + +object SharedPrefUtils { + fun saveLoginTypePref(context:Context, type: LoginActivity.LoginType) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + it.edit().putString(Constants.Keys.LoginType, type.value).apply() + } + } + + fun clearLoginTypePref(context:Context) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + it.edit().remove(Constants.Keys.LoginType).apply() + } + } + + fun getLoginTypePref(context:Context): String? { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + return pref.getString(Constants.Keys.LoginType, null) + } + + return null + } + + fun saveEmailPref(context:Context, email: String) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + pref?.edit()?.putString(Constants.Keys.Email, email)?.apply() + } + + fun clearEmailPref(context:Context) { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + pref?.edit()?.remove(Constants.Keys.Email)?.apply() + } + + fun getEmailPref(context:Context): String? { + val pref = context.getSharedPreferences(Constants.Keys.KitchenSinkSharedPref, Context.MODE_PRIVATE) + + pref?.let { + return pref.getString(Constants.Keys.Email, null) + } + + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt new file mode 100644 index 0000000..f20501a --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/StringExtension.kt @@ -0,0 +1,13 @@ +package com.ciscowebex.androidsdk.kitchensink.utils.extensions + +import android.util.Patterns + +fun CharSequence?.isValidEmail() = !isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() + + +/** + * Converts offset to the equivalent offset into this String encoded as UTF-8. + */ +fun String.utf8Offset(offset: Int): Int { + return substring(0, offset).toByteArray().size +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt new file mode 100644 index 0000000..d09ad59 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/utils/extensions/ViewExtension.kt @@ -0,0 +1,19 @@ +package com.ciscowebex.androidsdk.kitchensink.utils.extensions + +import android.app.Activity +import android.content.Context +import android.util.Log +import android.view.View +import android.view.inputmethod.InputMethodManager + +fun View.showKeyboard() { + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0) + Log.d("ViewExtensions", "showKeyboard()") +} + +fun Context.hideKeyboard(view: View) { + val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + Log.d("ViewExtensions", "hideKeyboard()") +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt new file mode 100644 index 0000000..6285ab4 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhookActionBottomSheetFragment.kt @@ -0,0 +1,42 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetWebhookActionBinding +import com.ciscowebex.androidsdk.webhook.Webhook +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + + +class WebhookActionBottomSheetFragment(val getDetails: (String) -> Unit, + val delete: (String) -> Unit, + val update: (String, Webhook?) -> Unit) : BottomSheetDialogFragment() { + + private lateinit var binding: BottomSheetWebhookActionBinding + lateinit var webhookId: String + var webhookModel: Webhook? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return BottomSheetWebhookActionBinding.inflate(inflater, container, false).also { binding = it }.apply { + + webhookGetDetails.setOnClickListener { + dismiss() + getDetails(webhookId) + } + + webhookDelete.setOnClickListener { + dismiss() + delete(webhookId) + } + + webhookUpdate.setOnClickListener { + dismiss() + update(webhookId, webhookModel) + } + + cancel.setOnClickListener { dismiss() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt new file mode 100644 index 0000000..b45314b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksActivity.kt @@ -0,0 +1,216 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import android.os.Bundle +import android.text.Editable +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityWebhooksBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogWebhookCreateBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.DialogWebhookUpdateBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.FragmentDialogWebhookDetailsBinding +import com.ciscowebex.androidsdk.kitchensink.databinding.ListItemWebhookBinding +import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage +import com.ciscowebex.androidsdk.webhook.Webhook +import org.koin.android.ext.android.inject + + +class WebhooksActivity : AppCompatActivity() { + var tag = "WebhooksActivity" + private lateinit var binding: ActivityWebhooksBinding + private lateinit var webhookAdapter: WebhookListAdapter + private val webhookModel : WebhooksViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DataBindingUtil.setContentView(this, R.layout.activity_webhooks) + .also { binding = it } + .apply { + val optionsDialogFragment = WebhookActionBottomSheetFragment ( + { webhookId -> webhookModel.get(webhookId)}, + { webhookId -> webhookModel.delete(webhookId)}, + { webhookId, model -> updateWebhookDialog(webhookId, model)}) + + swipeContainer.setOnRefreshListener { + updateList() + } + + addWebhookButton.setOnClickListener { + createWebhookDialog() + } + + val dividerItemDecoration = DividerItemDecoration(this@WebhooksActivity, LinearLayoutManager.VERTICAL) + webhookRecyclerView.addItemDecoration(dividerItemDecoration) + webhookAdapter = WebhookListAdapter(optionsDialogFragment, supportFragmentManager) + webhookRecyclerView.adapter = webhookAdapter + + webhookModel.webhooksList.observe(this@WebhooksActivity, Observer { + it?.let { + swipeContainer.isRefreshing = false + webhookAdapter.webhookList.clear() + webhookAdapter.webhookList.addAll(it) + webhookAdapter.notifyDataSetChanged() + } + }) + + webhookModel.webhooksError.observe(this@WebhooksActivity, Observer { + it?.let { + showDialogWithMessage(this@WebhooksActivity, R.string.webhook_error, it) + } ?: run { + showDialogWithMessage(this@WebhooksActivity, R.string.webhook_error, "") + } + }) + + webhookModel.webhookData.observe(this@WebhooksActivity, Observer { + it?.let { + when (WebhooksViewModel.WebhookEvent.valueOf(it.first.name)) { + WebhooksViewModel.WebhookEvent.CREATE -> { + Log.d(tag, "WebhookEvent.CREATE") + updateList() + } + WebhooksViewModel.WebhookEvent.GET -> { + Log.d(tag, "WebhookEvent.GET") + webhookDetails(it.second) + } + WebhooksViewModel.WebhookEvent.UPDATE -> { + Log.d(tag, "WebhookEvent.UPDATE") + webhookDetails(it.second) + } + } + } + }) + + webhookModel.deleteWebhook.observe(this@WebhooksActivity, Observer { delete -> + delete?.let { + updateList() + } + }) + + } + } + + private fun updateList() { + webhookModel.list(resources.getInteger(R.integer.webhook_list_max)) + } + + private fun createWebhookDialog() { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + DialogWebhookCreateBinding.inflate(layoutInflater) + .apply { + builder.setView(this.root) + + builder.setPositiveButton(getString(R.string.webhook_create)) { dialog, _ -> + val name = nameEditText.text.toString() + val targetUrl = targetUrlEditText.text.toString() + val resource = resourceEditText.text.toString() + val event = eventEditText.text.toString() + val filter: String? = if (filterEditText.text.isNotEmpty()) filterEditText.text.toString() else null + val secret: String? = if (secretEditText.text.isNotEmpty()) secretEditText.text.toString() else null + + webhookModel.create(name, targetUrl, resource, event, filter, secret) + dialog.dismiss() + } + + builder.show() + } + } + + private fun updateWebhookDialog(webhookId: String, model: Webhook?) { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + DialogWebhookUpdateBinding.inflate(layoutInflater) + .apply { + builder.setView(this.root) + + model?.let { webhook -> + nameEditText.text = Editable.Factory.getInstance().newEditable(webhook.name) + targetUrlEditText.text = Editable.Factory.getInstance().newEditable(webhook.targetUrl) + + webhook.status?.let { + statusEditText.text = Editable.Factory.getInstance().newEditable(webhook.status) + } + + webhook.secret?.let { + secretEditText.text = Editable.Factory.getInstance().newEditable(webhook.secret) + } + } + + builder.setPositiveButton(getString(R.string.webhook_update)) { dialog, _ -> + + val name = nameEditText.text.toString() + val targetUrl = targetUrlEditText.text.toString() + val status: String? = if (statusEditText.text.isNotEmpty()) statusEditText.text.toString() else null + val secret: String? = if (secretEditText.text.isNotEmpty()) secretEditText.text.toString() else null + + webhookModel.update(webhookId, name, targetUrl, secret, status) + dialog.dismiss() + } + + builder.show() + } + } + + private fun webhookDetails(_webhook: Webhook) { + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + + FragmentDialogWebhookDetailsBinding.inflate(layoutInflater) + .apply { + webhook = _webhook + + builder.setView(this.root) + builder.setPositiveButton(android.R.string.ok) { dialog, _ -> + updateList() + dialog.dismiss() + } + + builder.show() + } + } + + class WebhookListAdapter(private val optionsDialogFragment: WebhookActionBottomSheetFragment, private val fragmentManager: FragmentManager) : RecyclerView.Adapter() { + var webhookList: MutableList = mutableListOf() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ListItemWebhookBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return webhookViewHolder(binding, optionsDialogFragment, fragmentManager) + } + + override fun getItemCount(): Int { + return webhookList.size + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as webhookViewHolder).bind(webhookList[position]) + } + + inner class webhookViewHolder(private val binding: ListItemWebhookBinding, private val optionsDialogFragment: WebhookActionBottomSheetFragment, private val fragmentManager: FragmentManager): RecyclerView.ViewHolder(binding.root) { + var webhook: Webhook? = null + + init { + binding.rootListItemLayout.setOnLongClickListener { _ -> + optionsDialogFragment.webhookId = webhook?.id ?: "" + optionsDialogFragment.webhookModel = webhook + + optionsDialogFragment.show(fragmentManager, "People Options") + + true + } + } + + fun bind(webhook: Webhook) { + this.webhook = webhook + binding.name.text = webhook.name + binding.path.text = webhook.targetUrl + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt new file mode 100644 index 0000000..57f842b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksModule.kt @@ -0,0 +1,13 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import com.ciscowebex.androidsdk.kitchensink.person.PersonRepository +import com.ciscowebex.androidsdk.kitchensink.person.PersonViewModel +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + + +val webhooksModule = module { + single { WebhooksRepository(get()) } + + viewModel { WebhooksViewModel(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt new file mode 100644 index 0000000..c115e86 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksRepository.kt @@ -0,0 +1,91 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import com.ciscowebex.androidsdk.Webex +import com.ciscowebex.androidsdk.CompletionHandler +import com.ciscowebex.androidsdk.webhook.Webhook +import io.reactivex.Observable +import io.reactivex.Single + + +class WebhooksRepository(private val webex: Webex) { + + fun list(max: Int) : Observable> { + return Single.create> { emitter -> + webex.webhooks.list(max, CompletionHandler { result -> + if (result.isSuccessful) { + val webhooksList = result.data + webhooksList?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onSuccess(emptyList()) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun create(name: String, targetUrl: String, resource: String, event: String, filter: String?, secret: String?) : Observable { + return Single.create { emitter -> + webex.webhooks.create(name, targetUrl, resource, event, filter, secret, CompletionHandler { result -> + if (result.isSuccessful) { + val webhook = result.data + webhook?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onError(Throwable("")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun get(webhookId: String) : Observable { + return Single.create { emitter -> + webex.webhooks.get(webhookId, CompletionHandler { result -> + if (result.isSuccessful) { + val webhook = result.data + webhook?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onError(Throwable("")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun update(webhookId: String, name: String, targetUrl: String, secret: String?, status: String?) : Observable { + return Single.create { emitter -> + webex.webhooks.update(webhookId, name, targetUrl, secret, status, CompletionHandler { result -> + if (result.isSuccessful) { + val webhook = result.data + webhook?.let { + emitter.onSuccess(it) + } ?: run { + emitter.onError(Throwable("")) + } + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } + + fun delete(webhookId: String) : Observable { + return Single.create { emitter -> + webex.webhooks.delete(webhookId, CompletionHandler { result -> + if (result.isSuccessful) { + emitter.onSuccess(true) + } else { + emitter.onError(Throwable(result.error?.errorMessage)) + } + }) + }.toObservable() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt new file mode 100644 index 0000000..6874c30 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/webhooks/WebhooksViewModel.kt @@ -0,0 +1,60 @@ +package com.ciscowebex.androidsdk.kitchensink.webhooks + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.ciscowebex.androidsdk.kitchensink.BaseViewModel +import com.ciscowebex.androidsdk.webhook.Webhook +import io.reactivex.android.schedulers.AndroidSchedulers + + +class WebhooksViewModel(private val webhookRepo: WebhooksRepository) : BaseViewModel() { + private val tag = "WebhooksViewModel" + + enum class WebhookEvent { + CREATE, + GET, + UPDATE + } + + private val _webhooksList = MutableLiveData>() + val webhooksList: LiveData> = _webhooksList + + private val _webhooksError = MutableLiveData() + val webhooksError: LiveData = _webhooksError + + private val _webhookData = MutableLiveData>() + val webhookData: LiveData> = _webhookData + + private val _deleteWebhook = MutableLiveData() + val deleteWebhook: LiveData = _deleteWebhook + + fun list(max: Int) { + webhookRepo.list(max).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhooksList.postValue(it) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun create(name: String, targetUrl: String, resource: String, event: String, filter: String?, secret: String?) { + webhookRepo.create(name, targetUrl, resource, event, filter, secret).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhookData.postValue(Pair(WebhookEvent.CREATE, it)) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun get(webhookId: String) { + webhookRepo.get(webhookId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhookData.postValue(Pair(WebhookEvent.GET, it)) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun update(webhookId: String, name: String, targetUrl: String, secret: String?, status: String?) { + webhookRepo.update(webhookId, name, targetUrl, secret, status).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _webhookData.postValue(Pair(WebhookEvent.UPDATE, it)) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } + + fun delete(webhookId: String) { + webhookRepo.delete(webhookId).observeOn(AndroidSchedulers.mainThread()).subscribe({ + _deleteWebhook.postValue(it) + }, { error -> _webhooksError.postValue(error.message) }).autoDispose() + } +} \ No newline at end of file diff --git a/app/src/main/res/anim/calling_animation.xml b/app/src/main/res/anim/calling_animation.xml new file mode 100644 index 0000000..5143328 --- /dev/null +++ b/app/src/main/res/anim/calling_animation.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/anim/cycle_animation.xml b/app/src/main/res/anim/cycle_animation.xml new file mode 100644 index 0000000..a697df1 --- /dev/null +++ b/app/src/main/res/anim/cycle_animation.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_notification_icon.png b/app/src/main/res/drawable/app_notification_icon.png new file mode 100644 index 0000000..2362462 Binary files /dev/null and b/app/src/main/res/drawable/app_notification_icon.png differ diff --git a/app/src/main/res/drawable/audio_button.xml b/app/src/main/res/drawable/audio_button.xml new file mode 100644 index 0000000..d7fdb15 --- /dev/null +++ b/app/src/main/res/drawable/audio_button.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_mute_active.xml b/app/src/main/res/drawable/audio_mute_active.xml new file mode 100644 index 0000000..4450ab4 --- /dev/null +++ b/app/src/main/res/drawable/audio_mute_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_mute_default.xml b/app/src/main/res/drawable/audio_mute_default.xml new file mode 100644 index 0000000..0fd9302 --- /dev/null +++ b/app/src/main/res/drawable/audio_mute_default.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/audio_mute_disable.xml b/app/src/main/res/drawable/audio_mute_disable.xml new file mode 100644 index 0000000..d312fd0 --- /dev/null +++ b/app/src/main/res/drawable/audio_mute_disable.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_gray_border.xml b/app/src/main/res/drawable/bg_gray_border.xml new file mode 100644 index 0000000..98cf7fd --- /dev/null +++ b/app/src/main/res/drawable/bg_gray_border.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_blue.xml b/app/src/main/res/drawable/circle_filled_blue.xml new file mode 100644 index 0000000..d84788d --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_blue.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_dark_gray.xml b/app/src/main/res/drawable/circle_filled_dark_gray.xml new file mode 100644 index 0000000..ad4c447 --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_dark_gray.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_deep_yellow.xml b/app/src/main/res/drawable/circle_filled_deep_yellow.xml new file mode 100644 index 0000000..2615605 --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_deep_yellow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_gray.xml b/app/src/main/res/drawable/circle_filled_gray.xml new file mode 100644 index 0000000..92190dd --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_gray.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_green.xml b/app/src/main/res/drawable/circle_filled_green.xml new file mode 100644 index 0000000..3fb728f --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_green.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_red.xml b/app/src/main/res/drawable/circle_filled_red.xml new file mode 100644 index 0000000..c561208 --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_red.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_filled_yellow.xml b/app/src/main/res/drawable/circle_filled_yellow.xml new file mode 100644 index 0000000..895dabf --- /dev/null +++ b/app/src/main/res/drawable/circle_filled_yellow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_bg.xml b/app/src/main/res/drawable/dialog_bg.xml new file mode 100644 index 0000000..c238783 --- /dev/null +++ b/app/src/main/res/drawable/dialog_bg.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_bg.xml b/app/src/main/res/drawable/edit_text_bg.xml new file mode 100644 index 0000000..e82dd7b --- /dev/null +++ b/app/src/main/res/drawable/edit_text_bg.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/google_contacts_android.png b/app/src/main/res/drawable/google_contacts_android.png new file mode 100644 index 0000000..224ea81 Binary files /dev/null and b/app/src/main/res/drawable/google_contacts_android.png differ diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..43aecde --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_24.xml b/app/src/main/res/drawable/ic_arrow_24.xml new file mode 100644 index 0000000..7ae8672 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_attachment_cancel.xml b/app/src/main/res/drawable/ic_attachment_cancel.xml new file mode 100644 index 0000000..352a5ca --- /dev/null +++ b/app/src/main/res/drawable/ic_attachment_cancel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_backspace.xml b/app/src/main/res/drawable/ic_backspace.xml new file mode 100644 index 0000000..54b2a22 --- /dev/null +++ b/app/src/main/res/drawable/ic_backspace.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 0000000..eb23254 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml new file mode 100644 index 0000000..0ce140a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_btn_camera_swap_40.xml b/app/src/main/res/drawable/ic_btn_camera_swap_40.xml new file mode 100644 index 0000000..e92ce6e --- /dev/null +++ b/app/src/main/res/drawable/ic_btn_camera_swap_40.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_call.xml b/app/src/main/res/drawable/ic_call.xml new file mode 100644 index 0000000..d323c9f --- /dev/null +++ b/app/src/main/res/drawable/ic_call.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_hold.xml b/app/src/main/res/drawable/ic_call_hold.xml new file mode 100644 index 0000000..794ffcc --- /dev/null +++ b/app/src/main/res/drawable/ic_call_hold.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_merge_24.xml b/app/src/main/res/drawable/ic_call_merge_24.xml new file mode 100644 index 0000000..ad5ecda --- /dev/null +++ b/app/src/main/res/drawable/ic_call_merge_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_transfer.xml b/app/src/main/res/drawable/ic_call_transfer.xml new file mode 100644 index 0000000..cc38ed2 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_transfer.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_waiting.xml b/app/src/main/res/drawable/ic_call_waiting.xml new file mode 100644 index 0000000..97859b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_waiting.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_call_white.xml b/app/src/main/res/drawable/ic_call_white.xml new file mode 100644 index 0000000..c9dfb99 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_24.xml b/app/src/main/res/drawable/ic_camera_24.xml new file mode 100644 index 0000000..5911166 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..aca2682 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cisco_gray_logo.png b/app/src/main/res/drawable/ic_cisco_gray_logo.png new file mode 100644 index 0000000..e00a156 Binary files /dev/null and b/app/src/main/res/drawable/ic_cisco_gray_logo.png differ diff --git a/app/src/main/res/drawable/ic_dialpad.xml b/app/src/main/res/drawable/ic_dialpad.xml new file mode 100644 index 0000000..0c96916 --- /dev/null +++ b/app/src/main/res/drawable/ic_dialpad.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_feeback.xml b/app/src/main/res/drawable/ic_feeback.xml new file mode 100644 index 0000000..b9da529 --- /dev/null +++ b/app/src/main/res/drawable/ic_feeback.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_incoming_call_legacy_36.xml b/app/src/main/res/drawable/ic_incoming_call_legacy_36.xml new file mode 100644 index 0000000..85b7719 --- /dev/null +++ b/app/src/main/res/drawable/ic_incoming_call_legacy_36.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard.xml b/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000..0ac5368 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_login.xml b/app/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000..0cdb259 --- /dev/null +++ b/app/src/main/res/drawable/ic_login.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..abc88eb --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..4350ba9 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_message.xml b/app/src/main/res/drawable/ic_message.xml new file mode 100644 index 0000000..af796a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_message.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_24.xml b/app/src/main/res/drawable/ic_mic_24.xml new file mode 100644 index 0000000..554fe75 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_off_24.xml b/app/src/main/res/drawable/ic_mic_off_24.xml new file mode 100644 index 0000000..e29527b --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..88a14ad --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml new file mode 100644 index 0000000..84497d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_outgoing_call.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_participant_add.xml b/app/src/main/res/drawable/ic_participant_add.xml new file mode 100644 index 0000000..f91af11 --- /dev/null +++ b/app/src/main/res/drawable/ic_participant_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_participant_list.xml b/app/src/main/res/drawable/ic_participant_list.xml new file mode 100644 index 0000000..0f10684 --- /dev/null +++ b/app/src/main/res/drawable/ic_participant_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_24.xml b/app/src/main/res/drawable/ic_people_24.xml new file mode 100644 index 0000000..bee216c --- /dev/null +++ b/app/src/main/res/drawable/ic_people_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..bd8dc80 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_remote_end.xml b/app/src/main/res/drawable/ic_remote_end.xml new file mode 100644 index 0000000..8efe293 --- /dev/null +++ b/app/src/main/res/drawable/ic_remote_end.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_remote_mute.xml b/app/src/main/res/drawable/ic_remote_mute.xml new file mode 100644 index 0000000..a208ce1 --- /dev/null +++ b/app/src/main/res/drawable/ic_remote_mute.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000..f5f6230 --- /dev/null +++ b/app/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_share.xml b/app/src/main/res/drawable/ic_screen_share.xml new file mode 100644 index 0000000..e5e7e5d --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_share.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_sharing.xml b/app/src/main/res/drawable/ic_screen_sharing.xml new file mode 100644 index 0000000..cb6b4f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_sharing.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_speaker.xml b/app/src/main/res/drawable/ic_speaker.xml new file mode 100644 index 0000000..7c8e50e --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_turn_off_video.xml b/app/src/main/res/drawable/ic_turn_off_video.xml new file mode 100644 index 0000000..e703d40 --- /dev/null +++ b/app/src/main/res/drawable/ic_turn_off_video.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/remote_video_view_border.xml b/app/src/main/res/drawable/remote_video_view_border.xml new file mode 100644 index 0000000..ca99097 --- /dev/null +++ b/app/src/main/res/drawable/remote_video_view_border.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/screen_sharing_active.xml b/app/src/main/res/drawable/screen_sharing_active.xml new file mode 100644 index 0000000..d71ff32 --- /dev/null +++ b/app/src/main/res/drawable/screen_sharing_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/screen_sharing_default.xml b/app/src/main/res/drawable/screen_sharing_default.xml new file mode 100644 index 0000000..a565f68 --- /dev/null +++ b/app/src/main/res/drawable/screen_sharing_default.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/speaker_button_selector.xml b/app/src/main/res/drawable/speaker_button_selector.xml new file mode 100644 index 0000000..c928087 --- /dev/null +++ b/app/src/main/res/drawable/speaker_button_selector.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/surfaceview_border.xml b/app/src/main/res/drawable/surfaceview_border.xml new file mode 100644 index 0000000..15623a2 --- /dev/null +++ b/app/src/main/res/drawable/surfaceview_border.xml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/surfaceview_transparent_border.xml b/app/src/main/res/drawable/surfaceview_transparent_border.xml new file mode 100644 index 0000000..e125d10 --- /dev/null +++ b/app/src/main/res/drawable/surfaceview_transparent_border.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/turn_off_video_active.xml b/app/src/main/res/drawable/turn_off_video_active.xml new file mode 100644 index 0000000..4a8f11b --- /dev/null +++ b/app/src/main/res/drawable/turn_off_video_active.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/turn_on_video_default.xml b/app/src/main/res/drawable/turn_on_video_default.xml new file mode 100644 index 0000000..6ab759d --- /dev/null +++ b/app/src/main/res/drawable/turn_on_video_default.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webex_teams_logo.png b/app/src/main/res/drawable/webex_teams_logo.png new file mode 100644 index 0000000..29cf4de Binary files /dev/null and b/app/src/main/res/drawable/webex_teams_logo.png differ diff --git a/app/src/main/res/layout/activity_call.xml b/app/src/main/res/layout/activity_call.xml new file mode 100644 index 0000000..056f8c2 --- /dev/null +++ b/app/src/main/res/layout/activity_call.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_cucm_login.xml b/app/src/main/res/layout/activity_cucm_login.xml new file mode 100644 index 0000000..d4d5d6f --- /dev/null +++ b/app/src/main/res/layout/activity_cucm_login.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_dialer.xml b/app/src/main/res/layout/activity_dialer.xml new file mode 100644 index 0000000..b69b4d1 --- /dev/null +++ b/app/src/main/res/layout/activity_dialer.xml @@ -0,0 +1,34 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_extras.xml b/app/src/main/res/layout/activity_extras.xml new file mode 100644 index 0000000..313af39 --- /dev/null +++ b/app/src/main/res/layout/activity_extras.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + +