diff --git a/app/src/main/java/com/networkscanner/app/ui/SettingsViewModel.kt b/app/src/main/java/com/networkscanner/app/ui/SettingsViewModel.kt index f68f6db..458da62 100644 --- a/app/src/main/java/com/networkscanner/app/ui/SettingsViewModel.kt +++ b/app/src/main/java/com/networkscanner/app/ui/SettingsViewModel.kt @@ -1,14 +1,12 @@ package com.networkscanner.app.ui import android.app.Application -import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import androidx.core.os.LocaleListCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.preference.PreferenceManager -import com.networkscanner.app.R import com.networkscanner.app.theme.ThemeManager import com.networkscanner.app.util.PrivilegedNeighborSource import kotlinx.coroutines.flow.MutableStateFlow @@ -62,6 +60,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private val _neighborSource = MutableStateFlow(PrivilegedNeighborSource.mode(application)) val neighborSource: StateFlow = _neighborSource.asStateFlow() + private val _neighborAvailability = + MutableStateFlow>(emptyMap()) + val neighborAvailability: StateFlow> = + _neighborAvailability.asStateFlow() + private val _neighborProbe = MutableStateFlow(NeighborProbe.Idle) + val neighborProbe: StateFlow = _neighborProbe.asStateFlow() private var probeJob: Job? = null fun setThemeMode(mode: ThemeManager.ThemeMode) { @@ -82,23 +86,49 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application applyLanguage(languageCode) } + /** Clears a stale failure and re-reads each backend's status, without prompting. */ + fun onNeighborPickerOpened() { + if (_neighborProbe.value is NeighborProbe.Failed) _neighborProbe.value = NeighborProbe.Idle + refreshNeighborAvailability() + } + + private fun refreshNeighborAvailability() { + viewModelScope.launch { + _neighborAvailability.value = PrivilegedNeighborSource.Mode.entries.associateWith { + PrivilegedNeighborSource.availability(it) + } + } + } + /** Persists [mode] only once its backend has granted access. */ fun setNeighborSource(mode: PrivilegedNeighborSource.Mode) { probeJob?.cancel() + _neighborProbe.value = NeighborProbe.Running(mode) probeJob = viewModelScope.launch { - if (!PrivilegedNeighborSource.probe(mode)) { - val message = if (mode == PrivilegedNeighborSource.Mode.ROOT) { - R.string.neighbor_source_root_unavailable - } else { - R.string.neighbor_source_shizuku_unavailable - } - Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show() + val granted = PrivilegedNeighborSource.probe(mode) + refreshNeighborAvailability() + if (!granted) { + _neighborProbe.value = NeighborProbe.Failed(mode) return@launch } prefs.edit { putString(PrivilegedNeighborSource.PREF_KEY, mode.name) } _neighborSource.value = mode + _neighborProbe.value = NeighborProbe.Idle } } + /** Abandons a pending access check, e.g. when the user keeps the current source instead. */ + fun cancelNeighborProbe() { + probeJob?.cancel() + _neighborProbe.value = NeighborProbe.Idle + } + fun supportsDynamicColors(): Boolean = ThemeManager.supportsDynamicColors() } + +/** Progress of the access check run when a MAC address source is chosen. */ +sealed interface NeighborProbe { + data object Idle : NeighborProbe + data class Running(val mode: PrivilegedNeighborSource.Mode) : NeighborProbe + data class Failed(val mode: PrivilegedNeighborSource.Mode) : NeighborProbe +} diff --git a/app/src/main/java/com/networkscanner/app/ui/screens/settings/NeighborSourcePickerSheet.kt b/app/src/main/java/com/networkscanner/app/ui/screens/settings/NeighborSourcePickerSheet.kt new file mode 100644 index 0000000..7595e53 --- /dev/null +++ b/app/src/main/java/com/networkscanner/app/ui/screens/settings/NeighborSourcePickerSheet.kt @@ -0,0 +1,176 @@ +package com.networkscanner.app.ui.screens.settings + +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.networkscanner.app.R +import com.networkscanner.app.ui.NeighborProbe +import com.networkscanner.app.util.PrivilegedNeighborSource.Availability +import com.networkscanner.app.util.PrivilegedNeighborSource.Mode + +/** Display label for a MAC address source, used for the sheet options and the row value badge. */ +@Composable +fun neighborSourceLabel(mode: Mode): String = when (mode) { + Mode.NONE -> stringResource(R.string.neighbor_source_none) + Mode.SHIZUKU -> stringResource(R.string.neighbor_source_shizuku) + Mode.ROOT -> stringResource(R.string.neighbor_source_root) +} + +/** One-line status under an option, or null when there is nothing worth saying. */ +@Composable +private fun neighborSourceStatus(mode: Mode, availability: Availability?, probe: NeighborProbe): String? = when { + probe is NeighborProbe.Running && probe.mode == mode -> + stringResource(R.string.neighbor_status_waiting) + probe is NeighborProbe.Failed && probe.mode == mode -> + stringResource( + if (mode == Mode.ROOT) R.string.neighbor_source_root_unavailable + else R.string.neighbor_source_shizuku_unavailable + ) + mode == Mode.NONE || availability == null -> null + mode == Mode.ROOT -> stringResource( + if (availability == Availability.UNAVAILABLE) R.string.neighbor_status_root_not_found + else R.string.neighbor_status_root_will_prompt + ) + else -> stringResource( + when (availability) { + Availability.READY -> R.string.neighbor_status_ready + Availability.WILL_PROMPT -> R.string.neighbor_status_shizuku_will_prompt + Availability.DENIED -> R.string.neighbor_status_shizuku_denied + Availability.OUTDATED -> R.string.neighbor_status_shizuku_outdated + Availability.UNAVAILABLE -> R.string.neighbor_status_shizuku_not_running + } + ) +} + +/** + * Single-choice picker for where MAC addresses are read from, styled like [LanguagePickerSheet]. + * Unlike the language picker it stays open while the chosen backend is asked for access, so a + * refusal is shown inline on that option; the caller dismisses it once the choice is applied. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NeighborSourcePickerSheet( + selected: Mode, + availability: Map, + probe: NeighborProbe, + onSelected: (Mode) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val haptics = LocalHapticFeedback.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.pref_neighbor_source_title), + style = MaterialTheme.typography.titleLarge + ) + Text( + text = stringResource(R.string.pref_neighbor_source_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Mode.entries.forEach { mode -> + val isSelected = mode == selected + val probing = probe is NeighborProbe.Running && probe.mode == mode + val failed = probe is NeighborProbe.Failed && probe.mode == mode + val status = neighborSourceStatus(mode, availability[mode], probe) + val shape = RoundedCornerShape(20.dp) + val contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + Surface( + shape = shape, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .selectable( + selected = isSelected, + enabled = !probing, + role = Role.RadioButton + ) { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelected(mode) + } + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = neighborSourceLabel(mode), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = contentColor + ) + status?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = if (failed) MaterialTheme.colorScheme.error + else contentColor.copy(alpha = 0.72f) + ) + } + } + when { + probing -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + isSelected -> Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingItems.kt b/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingItems.kt index 6528818..4c15936 100644 --- a/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingItems.kt +++ b/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingItems.kt @@ -1,8 +1,6 @@ package com.networkscanner.app.ui.screens.settings import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.toggleable @@ -10,9 +8,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -104,43 +99,3 @@ fun ClickableSettingItem( } ) } - -@Composable -fun SegmentedSettingItem( - title: String, - summary: String? = null, - icon: ImageVector? = null, - options: List>, - selected: T, - onSelected: (T) -> Unit -) { - val haptics = LocalHapticFeedback.current - ListItem( - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - headlineContent = { Text(title) }, - supportingContent = { - Column { - summary?.let { Text(it) } - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - ) { - options.forEachIndexed { index, (value, label) -> - SegmentedButton( - selected = selected == value, - onClick = { - haptics.performHapticFeedback(HapticFeedbackType.Confirm) - onSelected(value) - }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size) - ) { - Text(label) - } - } - } - } - }, - leadingContent = icon?.let { { SettingLeadingIcon(it) } } - ) -} diff --git a/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingsScreen.kt index ee5110a..a4bcfff 100644 --- a/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/networkscanner/app/ui/screens/settings/SettingsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,7 +49,6 @@ import com.networkscanner.app.BuildConfig import com.networkscanner.app.R import com.networkscanner.app.ui.SettingsViewModel import com.networkscanner.app.ui.components.SegmentSurface -import com.networkscanner.app.util.PrivilegedNeighborSource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,10 +62,13 @@ fun SettingsScreen( val autoScan by viewModel.autoScan.collectAsState() val language by viewModel.language.collectAsState() val neighborSource by viewModel.neighborSource.collectAsState() + val neighborAvailability by viewModel.neighborAvailability.collectAsState() + val neighborProbe by viewModel.neighborProbe.collectAsState() var showAboutDialog by remember { mutableStateOf(false) } var showPrivacyDialog by remember { mutableStateOf(false) } var showLanguageDialog by remember { mutableStateOf(false) } + var showNeighborSourceSheet by remember { mutableStateOf(false) } val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -177,17 +180,15 @@ fun SettingsScreen( ) } SegmentSurface(index = 1, count = 3) { - SegmentedSettingItem( + ClickableSettingItem( title = stringResource(R.string.pref_neighbor_source_title), summary = stringResource(R.string.pref_neighbor_source_summary), icon = Icons.Rounded.Key, - options = listOf( - PrivilegedNeighborSource.Mode.NONE to stringResource(R.string.neighbor_source_none), - PrivilegedNeighborSource.Mode.SHIZUKU to stringResource(R.string.neighbor_source_shizuku), - PrivilegedNeighborSource.Mode.ROOT to stringResource(R.string.neighbor_source_root) - ), - selected = neighborSource, - onSelected = { viewModel.setNeighborSource(it) } + value = neighborSourceLabel(neighborSource), + onClick = { + viewModel.onNeighborPickerOpened() + showNeighborSourceSheet = true + } ) } SegmentSurface(index = 2, count = 3) { @@ -268,6 +269,27 @@ fun SettingsScreen( if (showPrivacyDialog) { PrivacyDialog(onDismiss = { showPrivacyDialog = false }) } + if (showNeighborSourceSheet) { + // The sheet stays open while access is requested and closes once the new source is saved. + val openedWith = remember { neighborSource } + LaunchedEffect(neighborSource) { + if (neighborSource != openedWith) showNeighborSourceSheet = false + } + NeighborSourcePickerSheet( + selected = neighborSource, + availability = neighborAvailability, + probe = neighborProbe, + onSelected = { mode -> + if (mode == neighborSource) { + viewModel.cancelNeighborProbe() + showNeighborSourceSheet = false + } else { + viewModel.setNeighborSource(mode) + } + }, + onDismiss = { showNeighborSourceSheet = false } + ) + } if (showLanguageDialog) { LanguagePickerSheet( selectedLanguage = language, diff --git a/app/src/main/java/com/networkscanner/app/util/PrivilegedNeighborSource.kt b/app/src/main/java/com/networkscanner/app/util/PrivilegedNeighborSource.kt index 8aca0a5..70aca20 100644 --- a/app/src/main/java/com/networkscanner/app/util/PrivilegedNeighborSource.kt +++ b/app/src/main/java/com/networkscanner/app/util/PrivilegedNeighborSource.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import rikka.shizuku.Shizuku +import java.io.File import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.coroutines.resume @@ -31,6 +32,9 @@ object PrivilegedNeighborSource { enum class Mode { NONE, SHIZUKU, ROOT } + /** What choosing a mode would currently lead to, determined without prompting the user. */ + enum class Availability { READY, WILL_PROMPT, DENIED, OUTDATED, UNAVAILABLE } + const val PREF_KEY = "privileged_neighbor_source" private val COMMAND = arrayOf("ip", "-4", "neigh", "show") @@ -78,6 +82,31 @@ object PrivilegedNeighborSource { } } + /** + * Passive counterpart of [probe]: never shows a prompt. Root cannot be confirmed without + * asking the su manager, so a `su` binary on PATH only means [Availability.WILL_PROMPT]. + */ + suspend fun availability(mode: Mode): Availability = withContext(Dispatchers.IO) { + when (mode) { + Mode.NONE -> Availability.READY + Mode.ROOT -> if (suOnPath()) Availability.WILL_PROMPT else Availability.UNAVAILABLE + Mode.SHIZUKU -> try { + when { + !Shizuku.pingBinder() -> Availability.UNAVAILABLE + Shizuku.isPreV11() -> Availability.OUTDATED + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED -> Availability.READY + Shizuku.shouldShowRequestPermissionRationale() -> Availability.DENIED + else -> Availability.WILL_PROMPT + } + } catch (e: Exception) { + Availability.UNAVAILABLE + } + } + } + + private fun suOnPath(): Boolean = + System.getenv("PATH").orEmpty().split(':').any { it.isNotEmpty() && File(it, "su").exists() } + // The command goes through stdin because `su -c` is not universal (AOSP's su lacks it). private fun runSu(command: String): List = try { val process = ProcessBuilder("su").redirectErrorStream(true).start() diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 33a81b4..198c490 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -174,6 +174,14 @@ تم مسح سجل الأجهزة Shizuku غير قيد التشغيل أو تم رفض الإذن تم رفض صلاحيات الروت + جاهز + سيطلب Shizuku الإذن + تم رفض الإذن. اسمح به في تطبيق Shizuku + يجب تحديث Shizuku + Shizuku غير قيد التشغيل + سيطلب مدير الروت الوصول + لم يتم اكتشاف صلاحيات الروت على هذا الجهاز + في انتظار الوصول… فتح الإعدادات diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8295615..2554c50 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -195,6 +195,14 @@ Geräteverlauf gelöscht Shizuku läuft nicht oder die Berechtigung wurde verweigert Root-Zugriff wurde verweigert + Bereit + Shizuku fragt nach der Berechtigung + Berechtigung verweigert. Erlauben Sie sie in der Shizuku-App + Shizuku muss aktualisiert werden + Shizuku läuft nicht + Ihr Root-Manager fragt nach dem Zugriff + Auf diesem Gerät wurde kein Root gefunden + Warte auf Zugriff… Einstellungen öffnen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ccf1f4c..f538ae6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -174,6 +174,14 @@ Historial de dispositivos borrado Shizuku no está en ejecución o se denegó el permiso Se denegó el acceso root + Listo + Shizuku pedirá permiso + Permiso denegado. Concédelo en la app Shizuku + Shizuku necesita actualizarse + Shizuku no está en ejecución + Tu gestor de root pedirá acceso + No se detectó root en este dispositivo + Esperando acceso… Abrir configuración diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 63fa699..26a9bd4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -174,6 +174,14 @@ Cronologia dispositivi cancellata Shizuku non è in esecuzione o il permesso è stato negato Accesso root negato + Pronto + Shizuku chiederà il permesso + Permesso negato. Concedilo nell\'app Shizuku + Shizuku deve essere aggiornato + Shizuku non è in esecuzione + Il gestore root chiederà l\'accesso + Root non rilevato su questo dispositivo + In attesa dell\'accesso… Apri impostazioni diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index adbd8f0..dac32b8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -172,6 +172,14 @@ История устройств очищена Shizuku не запущен или в разрешении отказано В root-доступе отказано + Готово + Shizuku запросит разрешение + В разрешении отказано. Разрешите его в приложении Shizuku + Shizuku нужно обновить + Shizuku не запущен + Root-менеджер запросит доступ + Root не обнаружен на этом устройстве + Ожидание доступа… Открыть настройки diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 00db740..5de8c1a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -172,6 +172,14 @@ Історія пристрою очищена Shizuku не запущено або в дозволі відмовлено У root-доступі відмовлено + Готово + Shizuku запитає дозвіл + У дозволі відмовлено. Надайте його в застосунку Shizuku + Shizuku потрібно оновити + Shizuku не запущено + Root-менеджер запитає доступ + Root не виявлено на цьому пристрої + Очікування доступу… Відкрити налаштування diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cffd8ae..d2af2da 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -176,6 +176,14 @@ 设备历史已清除 Shizuku 未运行或权限被拒绝 Root 权限被拒绝 + 就绪 + Shizuku 将请求权限 + 权限被拒绝。请在 Shizuku 应用中允许 + Shizuku 需要更新 + Shizuku 未运行 + Root 管理器将请求访问权限 + 未在此设备上检测到 Root + 正在等待访问权限… 打开设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef41f5d..c0b76bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,6 +198,14 @@ Device history cleared Shizuku is not running or permission was denied Root access was denied + Ready + Shizuku will ask for permission + Permission denied. Allow it in the Shizuku app + Shizuku needs to be updated + Shizuku is not running + Your root manager will ask for access + Root not detected on this device + Waiting for access… Open settings