Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl
### Changed
- The sample's fake update feed offers 1.1.0 now that the sample is 1.0.0.
- README install lines use a `LATEST_VERSION` placeholder that matches the JitPack badge, plus a version catalog example.
- Drawer pages slide in and out instead of switching instantly.
- The network inspector's clear button sits in the count row, like the other lists.
- The device & app snapshot shows share, save and copy as small icons on the card; they are not part of the shared image.
- The deep link launcher opens links from a send icon inside the field, leaving more room on small phones.
- The sample app is named DevDeck.

### Fixed
- Search placeholders and the network count row stay on one line on small phones.
- The permissions list refreshes after a permission dialog or settings screen closes, and shows when a revoke only applies after the app restarts.

## [1.0.0] - 2026-10-24

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,10 @@ The last 50 steps are saved with every crash and shown as *Steps before the cras
### Screen capture, bug reports and device info

- **Screenshot / screen recording / gallery:** under User Interface. Recording asks for Android's screen-capture permission and shows a Stop notification.
- **Device & app snapshot:** under Info. One image with app version, build type, SDKs, device, Android version, screen and locale, plus your `buildInfo`. Share it, save it, or copy it as text.
- **Device & app snapshot:** under Info. One image with app version, build type, SDKs, device, Android version, screen and locale, plus your `buildInfo`. Share it, save it, or copy it as text from the icons on the card; the icons are left out of the image.
- **App settings:** the ⚙ button next to the drawer's close button opens the app's system settings page.
- **Bug report:** a description, recent steps, your logs, recent logcat, device info and chosen captures, sent through the share sheet.
- **Permissions:** under System. Each permission shows Granted or Denied with what you can do about it. *Request* shows the system dialog, or opens the app's settings once Android stops asking. *Revoke* (Android 13+) removes a runtime permission when the app closes. *Open settings* handles special permissions such as drawing over other apps. Install-time permissions can't change, so they are only listed. To grant or revoke silently, use `adb shell pm grant` / `pm revoke`.
- **Permissions:** under System. Each permission shows Granted or Denied with what you can do about it. *Request* shows the system dialog, or opens the app's settings once Android stops asking. *Revoke* (Android 13+) removes a runtime permission when the app closes; until then the row shows *Revoked when the app restarts*. The list refreshes when you come back from a permission dialog or settings screen. *Open settings* handles special permissions such as drawing over other apps. Install-time permissions can't change, so they are only listed. To grant or revoke silently, use `adb shell pm grant` / `pm revoke`.

---

Expand Down
93 changes: 66 additions & 27 deletions devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
Expand Down Expand Up @@ -41,7 +46,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -79,38 +83,73 @@ internal fun DeckPanel(

Surface(modifier = modifier, shape = shape, color = colors?.panel ?: MaterialTheme.colorScheme.surface, shadowElevation = 12.dp) {
CompositionLocalProvider(LocalDeckState provides state) {
Column(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing.only(insetSides))) {
val page = state.pages.lastOrNull()
if (page == null) {
DeckHeader(title = style.title, subtitle = style.subtitle, onClose = state::close)
} else {
DeckPageBar(title = page.title, onBack = { state.navigateBack() }, onClose = state::close)
}
key(page) {
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 4.dp, bottom = 16.dp),
) {
if (page == null) {
scope.drawerContent()
} else {
DeckCard(Modifier.padding(horizontal = 12.dp)) {
Spacer(Modifier.height(4.dp))
page.content(scope)
Spacer(Modifier.height(4.dp))
}
}
}
val page = state.pages.lastOrNull()
AnimatedContent(
targetState = PageEntry(page, state.pages.size),
transitionSpec = {
val forward = targetState.depth > initialState.depth
val enter = slideInHorizontally(tween(PAGE_TRANSITION_MILLIS)) { if (forward) it else -it / 3 }
val exit = slideOutHorizontally(tween(PAGE_TRANSITION_MILLIS)) { if (forward) -it / 3 else it }
(enter togetherWith exit).apply { targetContentZIndex = if (forward) 1f else -1f }
},
contentKey = { it.page },
label = "DeckPage",
) { entry ->
DeckPageContent(
entry.page,
state,
style,
scope,
colors?.panel ?: MaterialTheme.colorScheme.surface,
insetSides,
drawerContent,
)
}
}
}
}

private data class PageEntry(val page: DeckPage?, val depth: Int)

@Composable
private fun DeckPageContent(
page: DeckPage?,
state: DeckState,
style: DeckStyle,
scope: DeckScope,
background: Color,
insetSides: WindowInsetsSides,
drawerContent: @Composable DeckScope.() -> Unit,
) {
Column(Modifier.fillMaxSize().background(background).windowInsetsPadding(WindowInsets.safeDrawing.only(insetSides))) {
if (page == null) {
DeckHeader(title = style.title, subtitle = style.subtitle, onClose = state::close)
} else {
DeckPageBar(title = page.title, onBack = { state.navigateBack() }, onClose = state::close)
}
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 4.dp, bottom = 16.dp),
) {
if (page == null) {
scope.drawerContent()
} else {
DeckCard(Modifier.padding(horizontal = 12.dp)) {
Spacer(Modifier.height(4.dp))
page.content(scope)
Spacer(Modifier.height(4.dp))
}
}
}
}
}

private const val PAGE_TRANSITION_MILLIS = 280

@Composable
private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) {
val context = LocalContext.current
Expand Down
43 changes: 23 additions & 20 deletions devdeck/src/main/kotlin/io/devdeck/actions/DeepLinkLauncher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ package io.devdeck.actions
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -19,16 +19,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.core.net.toUri
import io.devdeck.components.BulletItem
import io.devdeck.components.SectionTitle
import io.devdeck.internal.DeckIcons
import io.devdeck.internal.DeckSettings

@Composable
Expand All @@ -40,22 +41,24 @@ public fun DeepLinkLauncher(modifier: Modifier = Modifier, suggestions: List<Str
if (launchDeepLink(context, link, restrictToApp)) recent = DeepLinkHistory.add(context, link)
}
Column(modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = uri,
onValueChange = { uri = it },
placeholder = { Text("myapp://product/123") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { open(uri.trim()) }),
modifier = Modifier.weight(1f),
)
Button(onClick = { open(uri.trim()) }, enabled = uri.isNotBlank()) { Text("Open") }
}
OutlinedTextField(
value = uri,
onValueChange = { uri = it },
placeholder = { Text("myapp://product/123", maxLines = 1, overflow = TextOverflow.Ellipsis) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { if (uri.isNotBlank()) open(uri.trim()) }),
trailingIcon = {
IconButton(onClick = { open(uri.trim()) }, enabled = uri.isNotBlank()) {
Icon(
DeckIcons.Send,
contentDescription = "Open deep link",
tint = MaterialTheme.colorScheme.primary.copy(alpha = if (uri.isBlank()) 0.38f else 1f),
)
}
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
if (suggestions.isNotEmpty()) {
SectionTitle("Suggested")
suggestions.forEach { link ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.devdeck.internal.DeckIcons

Expand All @@ -21,7 +22,7 @@ public fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier:
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(placeholder) },
placeholder = { Text(placeholder, maxLines = 1, overflow = TextOverflow.Ellipsis) },
singleLine = true,
shape = CircleShape,
leadingIcon = { Icon(DeckIcons.Search, contentDescription = null, tint = colors.onSurfaceVariant) },
Expand Down
5 changes: 5 additions & 0 deletions devdeck/src/main/kotlin/io/devdeck/device/AppPermissions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.compose.runtime.mutableStateListOf
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
Expand All @@ -31,6 +32,9 @@ internal val PermissionOrder: Comparator<AppPermission> =

internal object AppPermissions {

// Revocations requested this process; Android only applies them once the process dies.
val pendingRevocations = mutableStateListOf<String>()

fun load(context: Context): List<AppPermission> {
val info = runCatching {
context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
Expand Down Expand Up @@ -61,6 +65,7 @@ internal object AppPermissions {
fun revoke(activity: Activity, permission: AppPermission): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
activity.revokeSelfPermissionOnKill(permission.name)
if (permission.name !in pendingRevocations) pendingRevocations += permission.name
activity.moveTaskToBack(true)
return true
}
Expand Down
Loading
Loading