diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index e4c881079..af6df39aa 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -17,8 +17,8 @@ android { applicationId "app.omnivore.omnivore" minSdk 23 targetSdk 33 - versionCode 9 - versionName "0.0.10" + versionCode 15 + versionName "0.0.15" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -140,6 +140,9 @@ dependencies { implementation 'com.google.code.gson:gson:2.8.9' implementation 'com.pspdfkit:pspdfkit:8.4.1' + + implementation 'com.segment.analytics.kotlin:android:1.10.0' + implementation 'io.intercom.android:intercom-sdk-base:14.0.0' } apollo { diff --git a/android/Omnivore/app/src/main/AndroidManifest.xml b/android/Omnivore/app/src/main/AndroidManifest.xml index 0bbedccff..a78d2d2f0 100644 --- a/android/Omnivore/app/src/main/AndroidManifest.xml +++ b/android/Omnivore/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ package="app.omnivore.omnivore"> + + by homeViewModel.itemsLiveData.observeAsState(listOf()) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .background(MaterialTheme.colorScheme.background) + Box( + modifier = Modifier .fillMaxSize() - .padding(horizontal = 6.dp) + .pullRefresh(pullRefreshState) ) { - items(linkedItems) { item -> - LinkedItemCard( - item = item, - onClickHandler = { - if (item.isPDF()) { - val intent = Intent(context, PDFReaderActivity::class.java) - intent.putExtra("LINKED_ITEM_SLUG", item.slug) - context.startActivity(intent) - } else { - navController.navigate("WebReader/${item.slug}") - } - } - ) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + .padding(horizontal = 6.dp) + ) { + items(linkedItems) { item -> + LinkedItemCard( + item = item, + onClickHandler = { + if (item.isPDF()) { + val intent = Intent(context, PDFReaderActivity::class.java) + intent.putExtra("LINKED_ITEM_SLUG", item.slug) + context.startActivity(intent) + } else { + navController.navigate("WebReader/${item.slug}") + } + }, + actionHandler = { homeViewModel.handleLinkedItemAction(item.id, it) } + ) + } } - } - InfiniteListHandler(listState = listState) { - homeViewModel.load() + InfiniteListHandler(listState = listState) { + homeViewModel.load() + } + + PullRefreshIndicator( + refreshing = homeViewModel.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt index 6fc76e977..55969a73b 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt @@ -1,18 +1,24 @@ package app.omnivore.omnivore.ui.home +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.omnivore.omnivore.models.LinkedItem -import app.omnivore.omnivore.networking.Networker -import app.omnivore.omnivore.networking.search +import app.omnivore.omnivore.networking.* +import com.pspdfkit.analytics.Analytics import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val networker: Networker + private val networker: Networker, ): ViewModel() { private var cursor: String? = null private var items: List = listOf() @@ -26,6 +32,7 @@ class HomeViewModel @Inject constructor( // Live Data val searchTextLiveData = MutableLiveData("") val itemsLiveData = MutableLiveData>(listOf()) + var isRefreshing by mutableStateOf(false) fun updateSearchText(text: String) { searchTextLiveData.value = text @@ -37,6 +44,11 @@ class HomeViewModel @Inject constructor( } } + fun refresh() { + isRefreshing = true + load(true) + } + fun load(clearPreviousSearch: Boolean = false) { if (clearPreviousSearch) { cursor = null @@ -61,14 +73,49 @@ class HomeViewModel @Inject constructor( receivedIdx = thisSearchIdx cursor = searchResult.cursor - if (searchTextLiveData.value != "") { + if (searchTextLiveData.value != "" || clearPreviousSearch) { val previousItems = if (clearPreviousSearch) listOf() else searchedItems searchedItems = previousItems.plus(searchResult.items) - itemsLiveData.value = searchedItems + itemsLiveData.postValue(searchedItems) } else { items = items.plus(searchResult.items) - itemsLiveData.value = items + itemsLiveData.postValue(items) } + + CoroutineScope(Dispatchers.Main).launch { + isRefreshing = false + } + } + } + + fun handleLinkedItemAction(itemID: String, action: LinkedItemAction) { + when (action) { + LinkedItemAction.Delete -> { + removeItemFromList(itemID) + + viewModelScope.launch { + networker.deleteLinkedItem(itemID) + } + } + LinkedItemAction.Archive -> { + removeItemFromList(itemID) + viewModelScope.launch { + networker.archiveLinkedItem(itemID) + } + } + LinkedItemAction.Unarchive -> { + removeItemFromList(itemID) + viewModelScope.launch { + networker.unarchiveLinkedItem(itemID) + } + } + } + } + + private fun removeItemFromList(itemID: String) { + itemsLiveData.value?.let { + val newList = it.filter { item -> item.id != itemID } + itemsLiveData.postValue(newList) } } @@ -82,3 +129,9 @@ class HomeViewModel @Inject constructor( return query } } + +enum class LinkedItemAction { + Delete, + Archive, + Unarchive +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/linkedItemViews/LinkedItemCard.kt similarity index 71% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/linkedItemViews/LinkedItemCard.kt index ffbfec8f2..05bdb58d3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/LinkedItemCard.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/linkedItemViews/LinkedItemCard.kt @@ -1,13 +1,10 @@ -package app.omnivore.omnivore.ui.home +package app.omnivore.omnivore.ui.linkedItemViews +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -16,10 +13,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.omnivore.omnivore.models.LinkedItem +import app.omnivore.omnivore.ui.home.LinkedItemAction import coil.compose.rememberAsyncImagePainter +@OptIn(ExperimentalFoundationApi::class) @Composable -fun LinkedItemCard(item: LinkedItem, onClickHandler: () -> Unit) { +fun LinkedItemCard(item: LinkedItem, onClickHandler: () -> Unit, actionHandler: (LinkedItemAction) -> Unit) { + var isMenuExpanded by remember { mutableStateOf(false) } val publisherDisplayName = item.publisherDisplayName() Column { @@ -29,7 +29,11 @@ fun LinkedItemCard(item: LinkedItem, onClickHandler: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(12.dp) - .clickable(onClick = onClickHandler) + .combinedClickable( + onClick = onClickHandler, + onLongClick = { isMenuExpanded = true } + ) + .background(if (isMenuExpanded) Color.LightGray else Color.Transparent) ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -75,5 +79,12 @@ fun LinkedItemCard(item: LinkedItem, onClickHandler: () -> Unit) { } Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) + + LinkedItemContextMenu( + isExpanded = isMenuExpanded, + isArchived = item.isArchived, + onDismiss = { isMenuExpanded = false }, + actionHandler = actionHandler + ) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/linkedItemViews/LinkedItemContextMenu.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/linkedItemViews/LinkedItemContextMenu.kt new file mode 100644 index 000000000..ea11a4038 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/linkedItemViews/LinkedItemContextMenu.kt @@ -0,0 +1,52 @@ +package app.omnivore.omnivore.ui.linkedItemViews + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.List +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import app.omnivore.omnivore.ui.home.LinkedItemAction + +@Composable +fun LinkedItemContextMenu( + isExpanded: Boolean, + isArchived: Boolean, + onDismiss: () -> Unit, + actionHandler: (LinkedItemAction) -> Unit +) { + DropdownMenu( + expanded = isExpanded, + onDismissRequest = onDismiss + ) { + DropdownMenuItem( + text = { Text(if (isArchived) "Unarchive" else "Archive") }, + onClick = { + val action = if (isArchived) LinkedItemAction.Unarchive else LinkedItemAction.Archive + actionHandler(action) + onDismiss() + }, + leadingIcon = { + Icon( + Icons.Outlined.List, // TODO: use more appropriate icon + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { Text("Remove Item") }, + onClick = { + actionHandler(LinkedItemAction.Delete) + onDismiss() + }, + leadingIcon = { + Icon( + Icons.Outlined.Delete, + contentDescription = null + ) + } + ) + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt index bf9565765..65c71ad36 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt @@ -322,12 +322,13 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionMan p0.dismiss() return@OnPopupToolbarItemClickedListener true } -// 2 -> { -// Log.d("pdf", "user selected annotate action") + 2 -> { + Log.d("pdf", "user selected annotate action") + showAnnotationView("") // textSelectionController?.textSelection = null -// p0.dismiss() -// return@OnPopupToolbarItemClickedListener true -// } + p0.dismiss() + return@OnPopupToolbarItemClickedListener true + } 3 -> { val text = textSelectionController?.textSelection?.text ?: "" val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager @@ -349,7 +350,7 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionMan p0.menuItems = listOf( PopupToolbarMenuItem(1, R.string.pdf_highlight_menu_action), -// PopupToolbarMenuItem(2, R.string.annotate_menu_action), + PopupToolbarMenuItem(2, R.string.annotate_menu_action), PopupToolbarMenuItem(3, R.string.pdf_highlight_copy), ) } @@ -432,6 +433,7 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionMan actionMode = null clickedHighlight = null clickedHighlightPosition = null + textSelectionController?.textSelection = null viewModel.annotationUnderNoteEdit = null } @@ -443,8 +445,15 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionMan val annotationEditFragment = AnnotationEditFragment() annotationEditFragment.configure( onSave = { newNote -> - clickedHighlight?.let { highlight -> - viewModel.updateHighlightNote(highlight, newNote) + if (clickedHighlight != null) { + viewModel.updateHighlightNote(clickedHighlight!!, newNote) + } else { + pendingHighlightAnnotation?.let { annotation -> + val quote = textSelectionController?.textSelection?.text ?: "" + fragment.addAnnotationToPage(annotation, false) { + viewModel.syncHighlightUpdates(annotation, quote, listOf(), newNote) + } + } } resetHighlightTap() }, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt index 4f38ae3ca..620e333b0 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt @@ -101,7 +101,7 @@ class PDFReaderViewModel @Inject constructor( } } - fun syncHighlightUpdates(newAnnotation: Annotation, quote: String, overlapIds: List) { + fun syncHighlightUpdates(newAnnotation: Annotation, quote: String, overlapIds: List, note: String? = null) { val itemID = pdfReaderParamsLiveData.value?.item?.id ?: return val highlightID = UUID.randomUUID().toString() val shortID = UUID.randomUUID().toString().replace("-","").substring(0,8) @@ -130,7 +130,7 @@ class PDFReaderViewModel @Inject constructor( } } else { val createHighlightInput = CreateHighlightInput( - annotation = Optional.presentIfNotNull(null), + annotation = Optional.presentIfNotNull(note), articleId = itemID, id = highlightID, patch = newAnnotation.toInstantJson(), @@ -141,17 +141,16 @@ class PDFReaderViewModel @Inject constructor( viewModelScope.launch { networker.createHighlight(createHighlightInput) } + + if (note != null) { + storeUpdatedNoteLocally(newAnnotation, note!!) + } } } fun updateHighlightNote(annotation: Annotation, note: String) { // Save the updated note locally - val omnivoreHighlight = annotation.customData?.get("omnivoreHighlight") as? JSONObject - omnivoreHighlight?.put("editedNote", note) - omnivoreHighlight?.let { - Log.d("pdf", "setting custom data: $omnivoreHighlight") - annotation.customData = JSONObject().put("omnivoreHighlight", it) - } + storeUpdatedNoteLocally(annotation, note) // Sync update with data service viewModelScope.launch { @@ -165,6 +164,15 @@ class PDFReaderViewModel @Inject constructor( } } + private fun storeUpdatedNoteLocally(annotation: Annotation, note: String) { + val omnivoreHighlight = annotation.customData?.get("omnivoreHighlight") as? JSONObject + omnivoreHighlight?.put("editedNote", note) + omnivoreHighlight?.let { + Log.d("pdf", "setting custom data: $omnivoreHighlight") + annotation.customData = JSONObject().put("omnivoreHighlight", it) + } + } + fun deleteHighlight(annotation: Annotation) { val highlightID = pluckHighlightID(annotation) ?: return viewModelScope.launch { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt index af113b73e..7b4417258 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* @@ -28,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat.getSystemService import app.omnivore.omnivore.R +import app.omnivore.omnivore.ui.linkedItemViews.LinkedItemContextMenu import com.google.gson.Gson import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,6 +41,7 @@ import kotlin.math.roundToInt @Composable fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) { + var isMenuExpanded by remember { mutableStateOf(false) } var showWebPreferencesDialog by remember { mutableStateOf(false ) } val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null) @@ -94,12 +97,25 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod backgroundColor = MaterialTheme.colorScheme.surfaceVariant, title = {}, actions = { + // Disabling menu until we implement local persistence +// IconButton(onClick = { isMenuExpanded = true }) { +// Icon( +// imageVector = Icons.Filled.Menu, +// contentDescription = null +// ) +// } IconButton(onClick = { showWebPreferencesDialog = true }) { Icon( - imageVector = Icons.Filled.Settings, + imageVector = Icons.Filled.Settings, // TODO: set a better icon contentDescription = null ) } + LinkedItemContextMenu( + isExpanded = isMenuExpanded, + isArchived = webReaderParams!!.item.isArchived, + onDismiss = { isMenuExpanded = false }, + actionHandler = { webReaderViewModel.handleLinkedItemAction(webReaderParams!!.item.id, it) } + ) } ) @@ -254,7 +270,7 @@ class OmnivoreWebView(context: Context) : WebView(context) { // Called when the user selects a contextual menu item override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { - R.id.annotateHighlight -> { + R.id.annotateHighlight, R.id.annotate -> { val script = "var event = new Event('annotate');document.dispatchEvent(event);" evaluateJavascript(script) { mode.finish() diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index acdc45403..3bc3e0084 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -10,8 +10,11 @@ import app.omnivore.omnivore.DatastoreKeys import app.omnivore.omnivore.DatastoreRepository import app.omnivore.omnivore.models.LinkedItem import app.omnivore.omnivore.networking.* +import app.omnivore.omnivore.ui.home.LinkedItemAction import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.* @@ -62,6 +65,36 @@ class WebReaderViewModel @Inject constructor( } } + fun handleLinkedItemAction(itemID: String, action: LinkedItemAction) { + when (action) { + LinkedItemAction.Delete -> { + viewModelScope.launch { + networker.deleteLinkedItem(itemID) + popToHomeView(itemID) + } + } + LinkedItemAction.Archive -> { + viewModelScope.launch { + networker.archiveLinkedItem(itemID) + popToHomeView(itemID) + } + } + LinkedItemAction.Unarchive -> { + viewModelScope.launch { + networker.unarchiveLinkedItem(itemID) + popToHomeView(itemID) + } + } + } + } + + private fun popToHomeView(itemID: String) { + CoroutineScope(Dispatchers.Main).launch { + // TODO: pop to home + Log.d("maxx", "should pop to home and remove item with ID: $itemID") + } + } + fun handleIncomingWebMessage(actionID: String, jsonString: String) { when (actionID) { "createHighlight" -> { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt index c669be8b6..b0abce8d2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt @@ -53,6 +53,13 @@ fun RootView( } else { WelcomeScreen(viewModel = loginViewModel) } + + DisposableEffect(hasAuthToken) { + if (hasAuthToken) { + loginViewModel.registerUser() + } + onDispose {} + } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt index be6a3db98..2c47411f4 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/settings/SettingsContent.kt @@ -16,6 +16,8 @@ import app.omnivore.omnivore.Routes import app.omnivore.omnivore.ui.auth.LoginViewModel import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import io.intercom.android.sdk.Intercom +import io.intercom.android.sdk.IntercomSpace @OptIn(ExperimentalMaterial3Api::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @@ -51,6 +53,11 @@ fun SettingsView( .padding(horizontal = 16.dp) ) { LogoutButton { loginViewModel.logout() } + Button(onClick = { + Intercom.client().present(space = IntercomSpace.Messages) + }) { + Text(text = "Open Help Center") + } } } } diff --git a/android/Omnivore/app/src/main/res/menu/pdf_text_selection_menu.xml b/android/Omnivore/app/src/main/res/menu/pdf_text_selection_menu.xml deleted file mode 100644 index 37f081f74..000000000 --- a/android/Omnivore/app/src/main/res/menu/pdf_text_selection_menu.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/android/Omnivore/secrets.xml b/android/Omnivore/secrets.xml index 89a05bdea..2641c6d57 100644 --- a/android/Omnivore/secrets.xml +++ b/android/Omnivore/secrets.xml @@ -1,4 +1,7 @@ unset + unset + unset + unset \ No newline at end of file