diff options
Diffstat (limited to 'java/src')
5 files changed, 453 insertions, 0 deletions
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)) } +}  |