diff options
8 files changed, 475 insertions, 0 deletions
@@ -59,6 +59,15 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", + "PlatformComposeCore", + "PlatformComposeSceneTransitionLayout", + "androidx.compose.runtime_runtime", + "androidx.compose.material3_material3", + "androidx.compose.material_material-icons-extended", + "androidx.activity_activity-compose", + "androidx.compose.animation_animation-graphics", + "androidx.lifecycle_lifecycle-viewmodel-compose", + "androidx.lifecycle_lifecycle-runtime-compose", ], } diff --git a/java/res/drawable/checkbox.xml b/java/res/drawable/checkbox.xml new file mode 100644 index 00000000..189d01ff --- /dev/null +++ b/java/res/drawable/checkbox.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + <path + android:pathData="M10,0C4.48,0 0,4.48 0,10C0,15.52 4.48,20 10,20C15.52,20 20,15.52 20,10C20,4.48 15.52,0 10,0ZM10,18C5.59,18 2,14.41 2,10C2,5.59 5.59,2 10,2C14.41,2 18,5.59 18,10C18,14.41 14.41,18 10,18ZM5.4,9.6L8,12.2L14.6,5.6L16,7L8,15L4,11L5.4,9.6Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> +</vector> diff --git a/java/res/drawable/ic_play_circle_filled_24px.xml b/java/res/drawable/ic_play_circle_filled_24px.xml new file mode 100644 index 00000000..f67127ca --- /dev/null +++ b/java/res/drawable/ic_play_circle_filled_24px.xml @@ -0,0 +1,3 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="20dp" android:viewportHeight="20" android:viewportWidth="20" android:width="20dp"> + <path android:fillColor="#ffffff" android:fillType="evenOdd" android:pathData="M0,10C0,4.48 4.48,0 10,0C15.52,0 20,4.48 20,10C20,15.52 15.52,20 10,20C4.48,20 0,15.52 0,10ZM14,10L8,5.5V14.5L14,10Z"/> +</vector> diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt new file mode 100644 index 00000000..87fb7618 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.shareousel.ui.composable + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.android.intentresolver.icon.AdaptiveIcon +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.icon.ComposeIcon +import com.android.intentresolver.icon.ResourceIcon + +@Composable +fun Image(icon: ComposeIcon) { + when (icon) { + is AdaptiveIcon -> Image(icon.wrapped) + is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) + is ResourceIcon -> { + val localContext = LocalContext.current + val wrappedContext: Context = + object : ContextWrapper(localContext) { + override fun getResources(): Resources = icon.res + } + CompositionLocalProvider(LocalContext provides wrappedContext) { + Image(painterResource(icon.resId), contentDescription = null) + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt new file mode 100644 index 00000000..a1ccd9dd --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.shareousel.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.android.intentresolver.R + +@Composable +fun ShareouselCard( + image: @Composable () -> Unit, + selected: Boolean, + onActionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + image() + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).fillMaxSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + ActionButton( + onActionClick, + modifier = + Modifier.background( + MaterialTheme.colorScheme.secondary, + shape = RoundedCornerShape(12.dp), + ) + .size(32.dp) + .align(Alignment.BottomEnd) + ) + } + } +} + +@Composable +private fun ActionButton( + onActionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton(onClick = { onActionClick() }, modifier = modifier) { + Icon( + Icons.Outlined.Edit, + contentDescription = "edit", + tint = Color(0xFF1B1C14), + modifier = Modifier.padding(8.dp) + ) + } +} + +@Composable +private fun AnimationIcon(modifier: Modifier = Modifier) { + Icon( + painterResource(id = R.drawable.ic_play_circle_filled_24px), + "animating", + tint = Color.White, + modifier = Modifier.size(20.dp).then(modifier) + ) +} + +@Composable +private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { + if (selected) { + val bgColor = MaterialTheme.colorScheme.primary + Icon( + painter = painterResource(id = R.drawable.checkbox), + tint = Color.White, + contentDescription = "selected", + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000) + ) + .size(20.dp) + .drawBehind { + drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f) + } + .then(modifier) + ) + } else { + Box( + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000), + ) + .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) + .clip(CircleShape) + .size(20.dp) + .background(color = Color(0x7DC4C4C4)) + .then(modifier) + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt new file mode 100644 index 00000000..c83c10b0 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.shareousel.ui.composable + +import android.os.Parcelable +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel +import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel + +@Composable +fun Shareousel(viewModel: ShareouselViewModel) { + val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle(initialValue = emptyList()) + val centerIdx by viewModel.centerIndex.collectAsStateWithLifecycle(initialValue = 0) + Column { + // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + val carouselState = + rememberLazyListState( + initialFirstVisibleItemIndex = centerIdx, + ) + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.fillMaxWidth() + .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + ) { + items(previewKeys, key = { (it as? Parcelable) ?: Unit }) { key -> + ShareouselCard(viewModel.previewForKey(key)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + + val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(actions) { actionViewModel -> + ShareouselAction( + label = actionViewModel.label, + onClick = actionViewModel.onClick, + ) { + actionViewModel.icon?.let { Image(it) } + } + } + } + } +} + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f + +@Composable +private fun ShareouselCard(viewModel: ShareouselImageViewModel) { + val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + val contentDescription by + viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null) + val borderColor = MaterialTheme.colorScheme.primary + + ShareouselCard( + image = { + bitmap?.let { bitmap -> + val aspectRatio = + (bitmap.width.toFloat() / bitmap.height.toFloat()) + // TODO: max ratio is actually equal to the viewport ratio + .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } + ?: run { + // TODO: look at ScrollableImagePreviewView.setLoading() + Box(modifier = Modifier.aspectRatio(2f / 5f)) + } + }, + selected = selected, + onActionClick = { viewModel.onActionClick() }, + modifier = + Modifier.thenIf(selected) { + Modifier.border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp) + ) + } + .clip(RoundedCornerShape(size = 12.dp)) + .clickable { viewModel.setSelected(!selected) }, + ) +} + +@Composable +private fun ShareouselAction( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, +) { + AssistChip( + onClick = onClick, + label = { Text(label) }, + leadingIcon = leadingIcon, + modifier = modifier + ) +} + +inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = + if (condition) this.then(factory()) else this diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt new file mode 100644 index 00000000..39f2040b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.shareousel.ui.viewmodel + +import android.graphics.Bitmap +import com.android.intentresolver.icon.ComposeIcon +import kotlinx.coroutines.flow.Flow + +data class ShareouselViewModel( + val headline: Flow<String>, + val previewKeys: Flow<List<Any>>, + val actions: Flow<List<ActionChipViewModel>>, + val centerIndex: Flow<Int>, + val previewForKey: (key: Any) -> ShareouselImageViewModel, +) + +data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) + +data class ShareouselImageViewModel( + val bitmap: Flow<Bitmap?>, + val contentDescription: Flow<String>, + val isSelected: Flow<Boolean>, + val setSelected: (Boolean) -> Unit, + val onActionClick: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/icon/ComposeIcon.kt b/java/src/com/android/intentresolver/icon/ComposeIcon.kt new file mode 100644 index 00000000..dbea1e55 --- /dev/null +++ b/java/src/com/android/intentresolver/icon/ComposeIcon.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.icon + +import android.content.ContentResolver +import android.content.pm.PackageManager +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import java.io.File +import java.io.FileInputStream + +sealed interface ComposeIcon + +data class BitmapIcon(val bitmap: Bitmap) : ComposeIcon + +data class ResourceIcon(val resId: Int, val res: Resources) : ComposeIcon + +@JvmInline value class AdaptiveIcon(val wrapped: ComposeIcon) : ComposeIcon + +fun Icon.toComposeIcon(pm: PackageManager, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> pm.resourcesForPackage(resPackage)?.let { ResourceIcon(resId, it) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +fun Icon.toComposeIcon(resources: Resources?, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> resources?.let { ResourceIcon(resId, resources) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +// TODO: this is probably constant and doesn't need to be re-queried for each icon +fun PackageManager.resourcesForPackage(pkgName: String): Resources? { + return if (pkgName == "android") { + Resources.getSystem() + } else { + runCatching { + this@resourcesForPackage.getApplicationInfo( + pkgName, + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_SHARED_LIBRARY_FILES + ) + } + .getOrNull() + ?.let { ai -> getResourcesForApplication(ai) } + } +} + +private fun Icon.uriIcon(resolver: ContentResolver): BitmapIcon? { + return runCatching { + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_FILE -> resolver.openInputStream(uri) + else -> FileInputStream(File(uriString)) + } + } + .getOrNull() + ?.let { inStream -> BitmapIcon(BitmapFactory.decodeStream(inStream)) } +} |