Add shortcut suggestions for rooms, remove then when leaving (#5180)
* Report shortcut usage for outgoing messages This patch adds support for creating and pushing dynamic long-lived shortcuts for outgoing messages. This together with an existing reference to the roomId used by the shortcuts as an identifer allows conversations to be prioritized. See https://developer.android.com/training/sharing/direct-share-targets#report-usage-outgoing * Simplify how to get the other user in a DM room * Add initial avatar icons to shortcuts * Remove room shortcuts when they're no longer joined * Try using API 33 for the new tests. They worked locally with API 30, so it's weird the CI asks for a higher API version. * Add observers for the pin code and session logout states. With this we can prevent new shortcuts from being created and remove existing ones when needed. * Wrap all calls to `ShortcutManagerCompat` with `runCatchingExceptions` to avoid crashes * Make `DefaultNotificationConversationService` a singleton. --------- Co-authored-by: networkException <git@nwex.de> Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
35928e3630
commit
9bc2c4a776
27 changed files with 681 additions and 27 deletions
|
|
@ -32,22 +32,17 @@ class DefaultNotificationBitmapLoader @Inject constructor(
|
|||
@ApplicationContext private val context: Context,
|
||||
private val sdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : NotificationBitmapLoader {
|
||||
/**
|
||||
* Get icon of a room.
|
||||
* @param path mxc url
|
||||
* @param imageLoader Coil image loader
|
||||
*/
|
||||
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
|
||||
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
|
||||
if (path == null) {
|
||||
return null
|
||||
}
|
||||
return loadRoomBitmap(path, imageLoader)
|
||||
return loadRoomBitmap(path, imageLoader, targetSize)
|
||||
}
|
||||
|
||||
private suspend fun loadRoomBitmap(path: String, imageLoader: ImageLoader): Bitmap? {
|
||||
private suspend fun loadRoomBitmap(path: String, imageLoader: ImageLoader, targetSize: Long): Bitmap? {
|
||||
return try {
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)))
|
||||
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(targetSize)))
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
val result = imageLoader.execute(imageRequest)
|
||||
|
|
@ -58,12 +53,6 @@ class DefaultNotificationBitmapLoader @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon of a user.
|
||||
* Before Android P, this does nothing because the icon won't be used
|
||||
* @param path mxc url
|
||||
* @param imageLoader Coil image loader
|
||||
*/
|
||||
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
|
||||
if (path == null || sdkIntProvider.get() < Build.VERSION_CODES.P) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications.conversations
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.libraries.core.coroutine.withPreviousValue
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
|
||||
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||
import io.element.android.libraries.push.impl.intent.IntentProvider
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationConversationService @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val intentProvider: IntentProvider,
|
||||
private val bitmapLoader: NotificationBitmapLoader,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val lockScreenService: LockScreenService,
|
||||
sessionObserver: SessionObserver,
|
||||
@AppCoroutineScope private val coroutineScope: CoroutineScope,
|
||||
) : NotificationConversationService {
|
||||
private val isRequestPinShortcutSupported = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
||||
|
||||
init {
|
||||
sessionObserver.addListener(object : SessionListener {
|
||||
override suspend fun onSessionCreated(userId: String) = Unit
|
||||
|
||||
override suspend fun onSessionDeleted(userId: String) {
|
||||
onSessionLogOut(SessionId(userId))
|
||||
}
|
||||
})
|
||||
|
||||
lockScreenService.isPinSetup()
|
||||
.withPreviousValue()
|
||||
.onEach { (hadPinCode, hasPinCode) ->
|
||||
if (hadPinCode == false && hasPinCode) {
|
||||
clearShortcuts()
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
override suspend fun onSendMessage(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
roomName: String,
|
||||
roomIsDirect: Boolean,
|
||||
roomAvatarUrl: String?,
|
||||
) {
|
||||
if (lockScreenService.isPinSetup().first()) {
|
||||
// We don't create shortcuts when a pin code is set for privacy reasons
|
||||
return
|
||||
}
|
||||
|
||||
val categories = setOfNotNull(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null
|
||||
)
|
||||
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
|
||||
val imageLoader = imageLoaderHolder.get(client)
|
||||
|
||||
val defaultShortcutIconSize = ShortcutManagerCompat.getIconMaxWidth(context)
|
||||
val useDarkTheme = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
val icon = bitmapLoader.getRoomBitmap(
|
||||
path = roomAvatarUrl,
|
||||
imageLoader = imageLoader,
|
||||
targetSize = defaultShortcutIconSize.toLong()
|
||||
)?.let(IconCompat::createWithBitmap)
|
||||
?: InitialsAvatarBitmapGenerator(useDarkTheme = useDarkTheme)
|
||||
.generateBitmap(defaultShortcutIconSize, AvatarData(id = roomId.value, name = roomName, size = AvatarSize.RoomHeader))
|
||||
?.let(IconCompat::createWithAdaptiveBitmap)
|
||||
|
||||
val shortcutInfo = ShortcutInfoCompat.Builder(context, "$sessionId-$roomId")
|
||||
.setShortLabel(roomName)
|
||||
.setIcon(icon)
|
||||
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null))
|
||||
.setCategories(categories)
|
||||
.setLongLived(true)
|
||||
.let {
|
||||
when (roomIsDirect) {
|
||||
true -> it.addCapabilityBinding("actions.intent.SEND_MESSAGE")
|
||||
false -> it.addCapabilityBinding("actions.intent.SEND_MESSAGE", "message.recipient.@type", listOf("Audience"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
runCatchingExceptions { ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo) }
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to create shortcut for room $roomId in session $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
val shortcutsToRemove = listOf("$sessionId-$roomId")
|
||||
runCatchingExceptions {
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove)
|
||||
if (isRequestPinShortcutSupported) {
|
||||
ShortcutManagerCompat.disableShortcuts(
|
||||
context,
|
||||
shortcutsToRemove,
|
||||
context.getString(CommonStrings.common_android_shortcuts_remove_reason_left_room)
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to remove shortcut for room $roomId in session $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set<RoomId>) {
|
||||
runCatchingExceptions {
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
|
||||
val shortcutsToRemove = mutableListOf<String>()
|
||||
shortcuts.filter { it.id.startsWith(sessionId.value) }
|
||||
.forEach { shortcut ->
|
||||
val roomId = RoomId(shortcut.id.removePrefix("$sessionId-"))
|
||||
if (!roomIds.contains(roomId)) {
|
||||
shortcutsToRemove.add(shortcut.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (shortcutsToRemove.isNotEmpty()) {
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove)
|
||||
if (isRequestPinShortcutSupported) {
|
||||
ShortcutManagerCompat.disableShortcuts(
|
||||
context,
|
||||
shortcutsToRemove,
|
||||
context.getString(CommonStrings.common_android_shortcuts_remove_reason_left_room)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to remove shortcuts for session $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearShortcuts() {
|
||||
runCatchingExceptions {
|
||||
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to clear all shortcuts")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSessionLogOut(sessionId: SessionId) {
|
||||
runCatchingExceptions {
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
val shortcutIdsToRemove = shortcuts.filter { it.id.startsWith(sessionId.value) }.map { it.id }
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIdsToRemove)
|
||||
|
||||
if (isRequestPinShortcutSupported) {
|
||||
ShortcutManagerCompat.disableShortcuts(
|
||||
context,
|
||||
shortcutIdsToRemove,
|
||||
context.getString(CommonStrings.common_android_shortcuts_remove_reason_session_logged_out)
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to remove shortcuts for session $sessionId after logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications.conversations
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.lockscreen.test.FakeLockScreenService
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider
|
||||
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
|
||||
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
||||
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
class DefaultNotificationConversationServiceTest {
|
||||
@Test
|
||||
fun `onSendMessage adds a shortcut`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
val service = createService(context)
|
||||
|
||||
service.onSendMessage(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
roomName = "Room title",
|
||||
roomIsDirect = false,
|
||||
roomAvatarUrl = null,
|
||||
)
|
||||
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
assertThat(shortcuts).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onLeftRoom removes a shortcut`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
val service = createService(context)
|
||||
|
||||
val shortcutId = "$A_SESSION_ID-$A_ROOM_ID"
|
||||
val shortcutInfo = ShortcutInfoCompat.Builder(context, shortcutId)
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
|
||||
// First we add the shortcut
|
||||
ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo)
|
||||
|
||||
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context).firstOrNull()?.id).isEqualTo(shortcutId)
|
||||
|
||||
service.onLeftRoom(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
)
|
||||
|
||||
// Then we check it's removed
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
assertThat(shortcuts).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onAvailableRoomsChanged keeps only the available rooms as shortcuts`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
val service = createService(context)
|
||||
|
||||
// We add a couple of shortcuts
|
||||
val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID")
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID_2")
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB))
|
||||
|
||||
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2)
|
||||
|
||||
service.onAvailableRoomsChanged(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomIds = setOf(A_ROOM_ID),
|
||||
)
|
||||
|
||||
// Then we check only the shortcuts for the matching rooms remain
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
assertThat(shortcuts).hasSize(1)
|
||||
assertThat(shortcuts.first().id).isEqualTo("$A_SESSION_ID-$A_ROOM_ID")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `on pin code enabled, all shortcuts are cleared`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
createService(context, lockScreenService = lockScreenService)
|
||||
|
||||
// Make sure the pin is disabled
|
||||
lockScreenService.setIsPinSetup(false)
|
||||
// Give the test some time to save the pin setup value
|
||||
runCurrent()
|
||||
|
||||
// We add a couple of shortcuts from different sessions
|
||||
val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID")
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID_2-$A_ROOM_ID_2")
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB))
|
||||
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2)
|
||||
|
||||
// Enable the pin code
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
// Give the test some time to save the new pin setup value
|
||||
runCurrent()
|
||||
|
||||
// Then we check there are no shortcuts left from any session
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
assertThat(shortcuts).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on session logged out, all shortcuts for the session are cleared`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
val sessionObserver = FakeSessionObserver()
|
||||
createService(context, sessionObserver = sessionObserver)
|
||||
|
||||
// Set the initial session state
|
||||
sessionObserver.onSessionCreated(A_SESSION_ID.value)
|
||||
sessionObserver.onSessionCreated(A_SESSION_ID_2.value)
|
||||
|
||||
// We add a couple of shortcuts from different sessions
|
||||
val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID")
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID_2-$A_ROOM_ID_2")
|
||||
.setShortLabel("Room title")
|
||||
.setIntent(Intent(Intent.ACTION_VIEW))
|
||||
.build()
|
||||
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB))
|
||||
assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2)
|
||||
|
||||
// A session is logged out
|
||||
sessionObserver.onSessionDeleted(A_SESSION_ID.value)
|
||||
|
||||
// Then we check the shortcuts for the logged out session are removed, but the rest remain
|
||||
val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context)
|
||||
assertThat(shortcuts).hasSize(1)
|
||||
assertThat(shortcuts.first().id).startsWith(A_SESSION_ID_2.value)
|
||||
}
|
||||
|
||||
private fun TestScope.createService(
|
||||
context: Context = InstrumentationRegistry.getInstrumentation().context,
|
||||
sessionObserver: FakeSessionObserver = FakeSessionObserver(),
|
||||
lockScreenService: FakeLockScreenService = FakeLockScreenService(),
|
||||
) = DefaultNotificationConversationService(
|
||||
context = context,
|
||||
intentProvider = FakeIntentProvider(),
|
||||
bitmapLoader = FakeNotificationBitmapLoader(),
|
||||
matrixClientProvider = FakeMatrixClientProvider(),
|
||||
imageLoaderHolder = FakeImageLoaderHolder(),
|
||||
sessionObserver = sessionObserver,
|
||||
lockScreenService = lockScreenService,
|
||||
coroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
|
@ -14,5 +14,5 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
|||
import io.element.android.libraries.push.impl.intent.IntentProvider
|
||||
|
||||
class FakeIntentProvider : IntentProvider {
|
||||
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent()
|
||||
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent(Intent.ACTION_VIEW)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue