Add new item type default icons and placeholders (#1404)

This commit is contained in:
Leendert de Borst
2026-01-04 21:49:28 +01:00
parent ecd7f78c93
commit 76997e807c
9 changed files with 892 additions and 70 deletions

View File

@@ -30,6 +30,7 @@ import net.aliasvault.app.autofill.models.FieldType
import net.aliasvault.app.autofill.utils.CredentialMatcher
import net.aliasvault.app.autofill.utils.FieldFinder
import net.aliasvault.app.autofill.utils.ImageUtils
import net.aliasvault.app.utils.ItemTypeIcon
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.interfaces.CredentialOperationCallback
import net.aliasvault.app.vaultstore.models.Credential
@@ -250,12 +251,8 @@ class AutofillService : AutofillService() {
* @return The dataset
*/
private fun createCredentialDataset(fieldFinder: FieldFinder, credential: Credential): Dataset {
// Choose layout based on whether we have a logo
val layoutId = if (credential.service.logo != null) {
R.layout.autofill_dataset_item_icon
} else {
R.layout.autofill_dataset_item
}
// Always use icon layout (will show logo or placeholder icon)
val layoutId = R.layout.autofill_dataset_item_icon
// Create presentation for this credential using our custom layout
val presentation = RemoteViews(packageName, layoutId)
@@ -366,13 +363,21 @@ class AutofillService : AutofillService() {
presentationDisplayValue,
)
// Set the logo if available
// Set the logo if available, otherwise use placeholder icon
val logoBytes = credential.service.logo
if (logoBytes != null) {
val bitmap = ImageUtils.bytesToBitmap(logoBytes)
if (bitmap != null) {
presentation.setImageViewBitmap(R.id.icon, bitmap)
}
val bitmap = if (logoBytes != null) {
ImageUtils.bytesToBitmap(logoBytes)
} else {
// Use placeholder key icon for Login/Alias items
ItemTypeIcon.getIcon(
context = this@AutofillService,
itemType = ItemTypeIcon.ItemType.LOGIN,
size = 96,
)
}
if (bitmap != null) {
presentation.setImageViewBitmap(R.id.icon, bitmap)
}
return dataSetBuilder.build()

View File

@@ -0,0 +1,356 @@
package net.aliasvault.app.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.graphics.Typeface
/**
* Item type icon helper - provides bitmap-based icons for different item types
* Matches the design from browser extension and iOS implementations
*/
object ItemTypeIcon {
/**
* Item type enumeration matching the database model
*/
object ItemType {
const val LOGIN = "Login"
const val ALIAS = "Alias"
const val CREDIT_CARD = "CreditCard"
const val NOTE = "Note"
}
/**
* Credit card brand type
*/
enum class CardBrand {
VISA,
MASTERCARD,
AMEX,
DISCOVER,
GENERIC,
;
companion object {
/**
* Detect credit card brand from card number using industry-standard prefixes
* @param cardNumber The card number to detect brand from
* @return The detected card brand
*/
fun detect(cardNumber: String?): CardBrand {
if (cardNumber.isNullOrEmpty()) {
return GENERIC
}
// Remove spaces and dashes
val cleaned = cardNumber.replace(Regex("[\\s-]"), "")
// Must be mostly numeric
if (!cleaned.matches(Regex("^\\d{4,}.*"))) {
return GENERIC
}
// Visa: starts with 4
if (cleaned.matches(Regex("^4.*"))) {
return VISA
}
// Mastercard: starts with 51-55 or 2221-2720
if (cleaned.matches(Regex("^5[1-5].*")) || cleaned.matches(Regex("^2[2-7].*"))) {
return MASTERCARD
}
// Amex: starts with 34 or 37
if (cleaned.matches(Regex("^3[47].*"))) {
return AMEX
}
// Discover: starts with 6011, 622, 644-649, 65
if (cleaned.matches(Regex("^6(?:011|22|4[4-9]|5).*"))) {
return DISCOVER
}
return GENERIC
}
}
}
// AliasVault color scheme
private const val COLOR_PRIMARY = "#f49541"
private const val COLOR_DARK = "#d68338"
private const val COLOR_LIGHT = "#ffe096"
private const val COLOR_LIGHTER = "#fbcb74"
/**
* Get the appropriate icon bitmap for an item type
* @param context Android context
* @param itemType The item type (Login, Alias, CreditCard, Note)
* @param cardNumber Optional card number for credit card brand detection
* @param size Icon size in pixels (default 96)
* @return Bitmap icon
*/
fun getIcon(
context: Context,
itemType: String,
cardNumber: String? = null,
size: Int = 96,
): Bitmap {
return when (itemType) {
ItemType.NOTE -> createNoteIcon(size)
ItemType.CREDIT_CARD -> {
val brand = CardBrand.detect(cardNumber)
getCardIcon(brand, size)
}
ItemType.LOGIN, ItemType.ALIAS -> createPlaceholderIcon(size)
else -> createPlaceholderIcon(size)
}
}
/**
* Get the appropriate icon for a credit card brand
*/
fun getCardIcon(brand: CardBrand, size: Int = 96): Bitmap {
return when (brand) {
CardBrand.VISA -> createVisaIcon(size)
CardBrand.MASTERCARD -> createMastercardIcon(size)
CardBrand.AMEX -> createAmexIcon(size)
CardBrand.DISCOVER -> createDiscoverIcon(size)
CardBrand.GENERIC -> createCreditCardIcon(size)
}
}
/**
* Create generic credit card icon
*/
private fun createCreditCardIcon(size: Int): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val scale = size / 32f
// Card background
paint.color = Color.parseColor(COLOR_PRIMARY)
canvas.drawRoundRect(
RectF(2 * scale, 6 * scale, 30 * scale, 26 * scale),
3 * scale,
3 * scale,
paint,
)
// Magnetic stripe
paint.color = Color.parseColor(COLOR_DARK)
canvas.drawRect(2 * scale, 11 * scale, 30 * scale, 15 * scale, paint)
// Chip
paint.color = Color.parseColor(COLOR_LIGHT)
canvas.drawRoundRect(
RectF(5 * scale, 18 * scale, 13 * scale, 20 * scale),
1 * scale,
1 * scale,
paint,
)
// Number line
paint.color = Color.parseColor(COLOR_LIGHTER)
canvas.drawRoundRect(
RectF(5 * scale, 22 * scale, 10 * scale, 23.5f * scale),
0.75f * scale,
0.75f * scale,
paint,
)
return bitmap
}
/**
* Create Visa icon
*/
private fun createVisaIcon(size: Int): Bitmap {
val bitmap = createCreditCardIcon(size)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Color.parseColor(COLOR_LIGHT)
paint.textSize = 6 * (size / 32f)
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
canvas.drawText("VISA", 8 * (size / 32f), 18 * (size / 32f), paint)
return bitmap
}
/**
* Create Mastercard icon
*/
private fun createMastercardIcon(size: Int): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val scale = size / 32f
// Card background
paint.color = Color.parseColor(COLOR_PRIMARY)
canvas.drawRoundRect(
RectF(2 * scale, 6 * scale, 30 * scale, 26 * scale),
3 * scale,
3 * scale,
paint,
)
// Left circle
paint.color = Color.parseColor(COLOR_DARK)
canvas.drawCircle(13 * scale, 16 * scale, 5 * scale, paint)
// Right circle
paint.color = Color.parseColor(COLOR_LIGHT)
canvas.drawCircle(19 * scale, 16 * scale, 5 * scale, paint)
// Overlap (simplified)
paint.color = Color.parseColor(COLOR_LIGHTER)
val path = Path()
path.addCircle(16 * scale, 16 * scale, 3.5f * scale, Path.Direction.CW)
canvas.drawPath(path, paint)
return bitmap
}
/**
* Create Amex icon
*/
private fun createAmexIcon(size: Int): Bitmap {
val bitmap = createCreditCardIcon(size)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Color.parseColor(COLOR_LIGHT)
paint.textSize = 8 * (size / 32f)
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
canvas.drawText("AMEX", 16 * (size / 32f), 18 * (size / 32f), paint)
return bitmap
}
/**
* Create Discover icon
*/
private fun createDiscoverIcon(size: Int): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val scale = size / 32f
// Card background
paint.color = Color.parseColor(COLOR_PRIMARY)
canvas.drawRoundRect(
RectF(2 * scale, 6 * scale, 30 * scale, 26 * scale),
3 * scale,
3 * scale,
paint,
)
// Circle logo
paint.color = Color.parseColor(COLOR_LIGHT)
canvas.drawCircle(20 * scale, 16 * scale, 4 * scale, paint)
// "DI" text
paint.textSize = 6 * scale
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
canvas.drawText("D", 7 * scale, 17 * scale, paint)
return bitmap
}
/**
* Create note/document icon
*/
private fun createNoteIcon(size: Int): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val scale = size / 32f
// Document body
paint.color = Color.parseColor(COLOR_PRIMARY)
val path = Path()
path.moveTo(8 * scale, 4 * scale)
path.lineTo(19 * scale, 4 * scale)
path.lineTo(26 * scale, 11 * scale)
path.lineTo(26 * scale, 26 * scale)
path.lineTo(8 * scale, 26 * scale)
path.close()
canvas.drawPath(path, paint)
// Folded corner
paint.color = Color.parseColor(COLOR_DARK)
val cornerPath = Path()
cornerPath.moveTo(19 * scale, 4 * scale)
cornerPath.lineTo(19 * scale, 11 * scale)
cornerPath.lineTo(26 * scale, 11 * scale)
cornerPath.close()
canvas.drawPath(cornerPath, paint)
// Text lines
paint.color = Color.parseColor(COLOR_LIGHT)
canvas.drawRoundRect(
RectF(10 * scale, 14 * scale, 22 * scale, 15.5f * scale),
0.75f * scale,
0.75f * scale,
paint,
)
canvas.drawRoundRect(
RectF(10 * scale, 18 * scale, 20 * scale, 19.5f * scale),
0.75f * scale,
0.75f * scale,
paint,
)
canvas.drawRoundRect(
RectF(10 * scale, 22 * scale, 18 * scale, 23.5f * scale),
0.75f * scale,
0.75f * scale,
paint,
)
return bitmap
}
/**
* Create placeholder key icon for Login/Alias
*/
private fun createPlaceholderIcon(size: Int): Bitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Color.parseColor(COLOR_PRIMARY)
paint.style = Paint.Style.STROKE
paint.strokeWidth = 2.5f * (size / 32f)
paint.strokeCap = Paint.Cap.ROUND
val scale = size / 32f
// Key bow (circular head)
canvas.drawCircle(10 * scale, 10 * scale, 6.5f * scale, paint)
// Key hole in bow
paint.strokeWidth = 2 * scale
canvas.drawCircle(10 * scale, 10 * scale, 2.5f * scale, paint)
// Key shaft - diagonal
paint.strokeWidth = 2.5f * scale
canvas.drawLine(15 * scale, 15 * scale, 27 * scale, 27 * scale, paint)
// Key teeth - perpendicular to shaft
canvas.drawLine(19 * scale, 19 * scale, 23 * scale, 15 * scale, paint)
canvas.drawLine(24 * scale, 24 * scale, 28 * scale, 20 * scale, paint)
return bitmap
}
}

View File

@@ -125,7 +125,7 @@ export default function ItemDetailsScreen() : React.ReactNode {
<ThemedContainer>
<ThemedScrollView>
<ThemedView style={styles.header}>
<ItemIcon logo={item.Logo} style={styles.logo} />
<ItemIcon item={item} style={styles.logo} />
<View style={styles.headerText}>
<ThemedText type="title" style={styles.serviceName}>
{item.Name}

View File

@@ -1,10 +1,8 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Image,
StyleSheet,
Text,
TouchableOpacity,
@@ -19,6 +17,7 @@ import { useColors } from '@/hooks/useColorScheme';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { ConfirmDeleteModal } from '@/components/items/ConfirmDeleteModal';
import { ItemIcon } from '@/components/items/ItemIcon';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
@@ -215,15 +214,6 @@ export default function RecentlyDeletedScreen(): React.ReactNode {
marginRight: 12,
width: 32,
},
itemLogoPlaceholder: {
alignItems: 'center',
backgroundColor: colors.primary + '20',
borderRadius: 4,
height: 32,
justifyContent: 'center',
marginRight: 12,
width: 32,
},
itemInfo: {
flex: 1,
},
@@ -284,16 +274,7 @@ export default function RecentlyDeletedScreen(): React.ReactNode {
<View key={item.Id} style={styles.itemCard}>
<View style={styles.itemContent}>
{/* Item logo */}
{item.Logo ? (
<Image
source={{ uri: `data:image/png;base64,${Buffer.from(item.Logo).toString('base64')}` }}
style={styles.itemLogo}
/>
) : (
<View style={styles.itemLogoPlaceholder}>
<MaterialIcons name="lock" size={18} color={colors.primary} />
</View>
)}
<ItemIcon item={item} style={styles.itemLogo} />
{/* Item info */}
<View style={styles.itemInfo}>

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-len */
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
@@ -289,7 +290,7 @@ export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode
activeOpacity={0.7}
>
<View style={styles.itemContent}>
<ItemIcon logo={item.Logo} style={styles.logo} />
<ItemIcon item={item} style={styles.logo} />
<View style={styles.itemInfo}>
<View style={styles.serviceNameRow}>
<Text style={styles.serviceName}>

View File

@@ -1,42 +1,290 @@
import { Buffer } from 'buffer';
import { Image, ImageStyle, StyleSheet } from 'react-native';
import { Image, ImageStyle, StyleSheet, View } from 'react-native';
import Svg, { Circle, Path, Rect, Text as SvgText } from 'react-native-svg';
import { SvgUri } from 'react-native-svg';
import type { Item } from '@/utils/dist/core/models/vault';
import { ItemTypes, FieldKey } from '@/utils/dist/core/models/vault';
import servicePlaceholder from '@/assets/images/service-placeholder.webp';
/**
* Item icon props.
* Item icon props - supports both legacy logo-only mode and new item-based mode.
*/
type ItemIconProps = {
/** Legacy: Logo bytes for Login/Alias items */
logo?: Uint8Array | number[] | string | null;
/** New: Full item object for type-aware icon rendering */
item?: Item;
style?: ImageStyle;
};
/**
* Item icon component.
* Credit card brand type
*/
export function ItemIcon({ logo, style }: ItemIconProps) : React.ReactNode {
type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'generic';
/**
* Detect credit card brand from card number using industry-standard prefixes
* @param cardNumber - The card number to detect brand from
* @returns The detected card brand
*/
const detectCardBrand = (cardNumber: string | undefined): CardBrand => {
if (!cardNumber) {
return 'generic';
}
// Remove spaces and dashes
const cleaned = cardNumber.replace(/[\s-]/g, '');
// Must be mostly numeric
if (!/^\d{4,}/.test(cleaned)) {
return 'generic';
}
// Visa: starts with 4
if (/^4/.test(cleaned)) {
return 'visa';
}
// Mastercard: starts with 51-55 or 2221-2720
if (/^5[1-5]/.test(cleaned) || /^2[2-7]/.test(cleaned)) {
return 'mastercard';
}
// Amex: starts with 34 or 37
if (/^3[47]/.test(cleaned)) {
return 'amex';
}
// Discover: starts with 6011, 622, 644-649, 65
if (/^6(?:011|22|4[4-9]|5)/.test(cleaned)) {
return 'discover';
}
return 'generic';
};
/**
* Generic credit card icon in AliasVault style
*/
const CreditCardIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
<Rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541" />
<Rect x="2" y="11" width="28" height="4" fill="#d68338" />
<Rect x="5" y="18" width="8" height="2" rx="1" fill="#ffe096" />
<Rect x="5" y="22" width="5" height="1.5" rx="0.75" fill="#fbcb74" />
</Svg>
);
/**
* Visa card icon in AliasVault style
*/
const VisaIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
<Rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541" />
<Path
d="M13.5 13L11.5 19H10L8.5 14.5C8.5 14.5 8.35 14 8 14C7.65 14 7 13.8 7 13.8L7.05 13.5H9.5C9.85 13.5 10.15 13.75 10.2 14.1L10.8 17L12.5 13.5H13.5V13ZM15 19H14L15 13H16L15 19ZM20 13.5C20 13.5 19.4 13.3 18.7 13.3C17.35 13.3 16.4 14 16.4 15C16.4 15.8 17.1 16.2 17.65 16.5C18.2 16.8 18.4 17 18.4 17.2C18.4 17.5 18.05 17.7 17.6 17.7C17 17.7 16.5 17.5 16.5 17.5L16.3 18.7C16.3 18.7 16.9 19 17.7 19C19.2 19 20.1 18.2 20.1 17.1C20.1 15.7 18.4 15.6 18.4 15C18.4 14.7 18.7 14.5 19.15 14.5C19.6 14.5 20.1 14.7 20.1 14.7L20.3 13.5H20V13.5ZM24 19L23.1 13.5H22C21.7 13.5 21.45 13.7 21.35 13.95L19 19H20.5L20.8 18H22.7L22.9 19H24ZM21.2 17L22 14.5L22.45 17H21.2Z"
fill="#ffe096"
/>
</Svg>
);
/**
* Mastercard icon in AliasVault style
*/
const MastercardIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
<Rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541" />
<Circle cx="13" cy="16" r="5" fill="#d68338" />
<Circle cx="19" cy="16" r="5" fill="#ffe096" />
<Path
d="M16 12.5C17.1 13.4 17.8 14.6 17.8 16C17.8 17.4 17.1 18.6 16 19.5C14.9 18.6 14.2 17.4 14.2 16C14.2 14.6 14.9 13.4 16 12.5Z"
fill="#fbcb74"
/>
</Svg>
);
/**
* Amex card icon in AliasVault style
*/
const AmexIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
<Rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541" />
<SvgText
x="16"
y="18"
textAnchor="middle"
fill="#ffe096"
fontSize="8"
fontWeight="bold"
fontFamily="Arial, sans-serif"
>
AMEX
</SvgText>
</Svg>
);
/**
* Discover card icon in AliasVault style
*/
const DiscoverIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
<Rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541" />
<Circle cx="20" cy="16" r="4" fill="#ffe096" />
<Path
d="M7 14H8.5C9.3 14 10 14.7 10 15.5C10 16.3 9.3 17 8.5 17H7V14Z"
fill="#ffe096"
/>
<Rect x="11" y="14" width="1.5" height="3" fill="#ffe096" />
<Path
d="M14 15C14 14.4 14.4 14 15 14C15.3 14 15.5 14.1 15.7 14.3L16.5 13.5C16.1 13.2 15.6 13 15 13C13.9 13 13 13.9 13 15C13 16.1 13.9 17 15 17C15.6 17 16.1 16.8 16.5 16.5L15.7 15.7C15.5 15.9 15.3 16 15 16C14.4 16 14 15.6 14 15Z"
fill="#ffe096"
/>
</Svg>
);
/**
* Note/document icon in AliasVault style
*/
const NoteIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
<Path
d="M8 4C6.9 4 6 4.9 6 6V26C6 27.1 6.9 28 8 28H24C25.1 28 26 27.1 26 26V11L19 4H8Z"
fill="#f49541"
/>
<Path d="M19 4V11H26L19 4Z" fill="#d68338" />
<Rect x="10" y="14" width="12" height="1.5" rx="0.75" fill="#ffe096" />
<Rect x="10" y="18" width="10" height="1.5" rx="0.75" fill="#ffe096" />
<Rect x="10" y="22" width="8" height="1.5" rx="0.75" fill="#ffe096" />
</Svg>
);
/**
* Placeholder icon for Login/Alias items - traditional key design with outline style
*/
const PlaceholderIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => (
<Svg width={width} height={height} viewBox="0 0 32 32" fill="none">
{/* Key bow (circular head) - positioned top-left */}
<Circle cx="10" cy="10" r="6.5" stroke="#f49541" strokeWidth="2.5" />
{/* Key hole in bow */}
<Circle cx="10" cy="10" r="2.5" stroke="#f49541" strokeWidth="2" />
{/* Key shaft - diagonal */}
<Path d="M15 15L27 27" stroke="#f49541" strokeWidth="2.5" strokeLinecap="round" />
{/* Key teeth - perpendicular to shaft */}
<Path d="M19 19L23 15" stroke="#f49541" strokeWidth="2.5" strokeLinecap="round" />
<Path d="M24 24L28 20" stroke="#f49541" strokeWidth="2.5" strokeLinecap="round" />
</Svg>
);
/**
* Get the appropriate icon component based on card brand
*/
const getCardIcon = (brand: CardBrand) => {
switch (brand) {
case 'visa':
return VisaIcon;
case 'mastercard':
return MastercardIcon;
case 'amex':
return AmexIcon;
case 'discover':
return DiscoverIcon;
default:
return CreditCardIcon;
}
};
/**
* Item icon component - supports both item-based and legacy logo-based rendering.
*/
export function ItemIcon({ logo, item, style }: ItemIconProps) : React.ReactNode {
const width = Number(style?.width ?? styles.logo.width);
const height = Number(style?.height ?? styles.logo.height);
// New item-based rendering mode
if (item) {
// For Note type, always show note icon
if (item.ItemType === ItemTypes.Note) {
return (
<View style={[styles.iconContainer, style]}>
<NoteIcon width={width} height={height} />
</View>
);
}
// For CreditCard type, detect card brand and show appropriate icon
if (item.ItemType === ItemTypes.CreditCard) {
const cardNumberField = item.Fields?.find(f => f.FieldKey === FieldKey.CardNumber);
const cardNumber = cardNumberField?.Value
? (Array.isArray(cardNumberField.Value) ? cardNumberField.Value[0] : cardNumberField.Value)
: undefined;
const brand = detectCardBrand(cardNumber);
const CardIcon = getCardIcon(brand);
return (
<View style={[styles.iconContainer, style]}>
<CardIcon width={width} height={height} />
</View>
);
}
// For Login/Alias types, use Logo if available, otherwise placeholder
const logoData = item.Logo;
if (logoData && logoData.length > 0) {
return renderLogo(logoData, style);
}
// Default placeholder for Login/Alias without logo
return (
<View style={[styles.iconContainer, style]}>
<PlaceholderIcon width={width} height={height} />
</View>
);
}
// Legacy logo-only rendering mode
if (logo && (typeof logo === 'string' || logo.length > 0)) {
return renderLogo(logo, style);
}
// Fallback to placeholder
return (
<View style={[styles.iconContainer, style]}>
<PlaceholderIcon width={width} height={height} />
</View>
);
}
/**
* Render logo from binary data.
*/
function renderLogo(
logoData: Uint8Array | number[] | string,
style?: ImageStyle
): React.ReactNode {
/**
* Get the logo source.
*/
const getLogoSource = (logoData: Uint8Array | number[] | string | null | undefined) : { type: 'image' | 'svg', source: string | number } => {
if (!logoData) {
const getLogoSource = (data: Uint8Array | number[] | string | null | undefined) : { type: 'image' | 'svg', source: string | number } => {
if (!data) {
return { type: 'image', source: servicePlaceholder };
}
try {
// If logo is already a base64 string (from iOS SQLite query result)
if (typeof logoData === 'string') {
const mimeType = detectMimeTypeFromBase64(logoData);
if (typeof data === 'string') {
const mimeType = detectMimeTypeFromBase64(data);
return {
type: mimeType === 'image/svg+xml' ? 'svg' : 'image',
source: `data:${mimeType};base64,${logoData}`
source: `data:${mimeType};base64,${data}`
};
}
// Handle binary data (from Android or other sources)
const logoBytes = toUint8Array(logoData);
const logoBytes = toUint8Array(data);
const base64Logo = Buffer.from(logoBytes).toString('base64');
const mimeType = detectMimeType(logoBytes);
return {
@@ -49,7 +297,7 @@ export function ItemIcon({ logo, style }: ItemIconProps) : React.ReactNode {
}
};
const logoSource = getLogoSource(logo);
const logoSource = getLogoSource(logoData);
if (logoSource.type === 'svg') {
/*
@@ -166,4 +414,8 @@ const styles = StyleSheet.create({
height: 32,
width: 32,
},
iconContainer: {
borderRadius: 4,
overflow: 'hidden',
},
});

View File

@@ -1,14 +1,23 @@
import SwiftUI
import Macaw
/// Service logo view
public struct ServiceLogoView: View {
/// Item logo view - displays logos or type-based icons for items
public struct ItemLogoView: View {
private let placeholderImageBase64 = "UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA==" // swiftlint:disable:this line_length
let logoData: Data?
let itemType: String?
let cardNumber: String?
@Environment(\.colorScheme) private var colorScheme
public init(logoData: Data?, itemType: String? = nil, cardNumber: String? = nil) {
self.logoData = logoData
self.itemType = itemType
self.cardNumber = cardNumber
}
private var colors: ColorConstants.Colors.Type {
ColorConstants.colors(for: colorScheme)
}
@@ -66,32 +75,91 @@ public struct ServiceLogoView: View {
public var body: some View {
Group {
if let logoData = logoData {
let mimeType = detectMimeType(logoData)
if mimeType == "image/svg+xml",
let svgNode = renderSVGNode(logoData) {
SVGImageView(node: svgNode)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let image = UIImage(data: logoData) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
// If itemType is specified, use type-based rendering
if let itemType = itemType {
renderTypeBasedIcon(itemType: itemType)
} else if let logoData = logoData {
// Legacy logo rendering
renderLogo(logoData: logoData)
} else {
// Fallback to placeholder
renderPlaceholder()
}
}
}
/// Render icon based on item type
private func renderTypeBasedIcon(itemType: String) -> some View {
Group {
// For Note type, always show note icon
if itemType == ItemTypeIcon.ItemType.note.rawValue {
renderSVGIcon(svg: ItemTypeIcon.noteIcon)
}
// For CreditCard type, detect brand and show appropriate icon
else if itemType == ItemTypeIcon.ItemType.creditCard.rawValue {
let brand = ItemTypeIcon.CardBrand.detect(from: cardNumber)
let cardIcon = ItemTypeIcon.getCardIcon(for: brand)
renderSVGIcon(svg: cardIcon)
}
// For Login/Alias types, use Logo if available, otherwise placeholder
else if let logoData = logoData, !logoData.isEmpty {
renderLogo(logoData: logoData)
} else {
renderSVGIcon(svg: ItemTypeIcon.placeholderIcon)
}
}
}
/// Render an SVG icon from string
private func renderSVGIcon(svg: String) -> some View {
Group {
if let svgData = svg.data(using: .utf8),
let svgNode = try? SVGParser.parse(text: svg) {
SVGImageView(node: svgNode)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
renderPlaceholder()
}
}
}
/// Render logo from binary data
private func renderLogo(logoData: Data) -> some View {
Group {
let mimeType = detectMimeType(logoData)
if mimeType == "image/svg+xml",
let svgNode = renderSVGNode(logoData) {
SVGImageView(node: svgNode)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let image = UIImage(data: logoData) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
renderPlaceholder()
}
}
}
/// Render fallback placeholder
private func renderPlaceholder() -> some View {
Group {
if let placeholder = placeholderImage {
Image(uiImage: placeholder)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
} else {
// Ultimate fallback if placeholder fails to load
Circle()
@@ -108,5 +176,5 @@ public struct ServiceLogoView: View {
}
#Preview {
ServiceLogoView(logoData: nil)
ItemLogoView(logoData: nil)
}

View File

@@ -0,0 +1,159 @@
// swiftlint:disable line_length
import SwiftUI
/// Item type icon helper - provides SVG-based icons for different item types
public struct ItemTypeIcon {
/// Item type enumeration matching the database model
public enum ItemType: String {
case login = "Login"
case alias = "Alias"
case creditCard = "CreditCard"
case note = "Note"
}
/// Credit card brand type
public enum CardBrand {
case visa
case mastercard
case amex
case discover
case generic
/// Detect credit card brand from card number using industry-standard prefixes
public static func detect(from cardNumber: String?) -> CardBrand {
guard let cardNumber = cardNumber else {
return .generic
}
// Remove spaces and dashes
let cleaned = cardNumber.replacingOccurrences(of: "[\\s-]", with: "", options: .regularExpression)
// Must be mostly numeric
guard cleaned.range(of: "^\\d{4,}", options: .regularExpression) != nil else {
return .generic
}
// Visa: starts with 4
if cleaned.hasPrefix("4") {
return .visa
}
// Mastercard: starts with 51-55 or 2221-2720
if cleaned.range(of: "^5[1-5]", options: .regularExpression) != nil ||
cleaned.range(of: "^2[2-7]", options: .regularExpression) != nil {
return .mastercard
}
// Amex: starts with 34 or 37
if cleaned.range(of: "^3[47]", options: .regularExpression) != nil {
return .amex
}
// Discover: starts with 6011, 622, 644-649, 65
if cleaned.range(of: "^6(?:011|22|4[4-9]|5)", options: .regularExpression) != nil {
return .discover
}
return .generic
}
}
/// Generic credit card icon SVG
public static let creditCardIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<rect x="2" y="11" width="28" height="4" fill="#d68338"/>
<rect x="5" y="18" width="8" height="2" rx="1" fill="#ffe096"/>
<rect x="5" y="22" width="5" height="1.5" rx="0.75" fill="#fbcb74"/>
</svg>
"""
/// Visa card icon SVG
public static let visaIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<path d="M13.5 13L11.5 19H10L8.5 14.5C8.5 14.5 8.35 14 8 14C7.65 14 7 13.8 7 13.8L7.05 13.5H9.5C9.85 13.5 10.15 13.75 10.2 14.1L10.8 17L12.5 13.5H13.5V13ZM15 19H14L15 13H16L15 19ZM20 13.5C20 13.5 19.4 13.3 18.7 13.3C17.35 13.3 16.4 14 16.4 15C16.4 15.8 17.1 16.2 17.65 16.5C18.2 16.8 18.4 17 18.4 17.2C18.4 17.5 18.05 17.7 17.6 17.7C17 17.7 16.5 17.5 16.5 17.5L16.3 18.7C16.3 18.7 16.9 19 17.7 19C19.2 19 20.1 18.2 20.1 17.1C20.1 15.7 18.4 15.6 18.4 15C18.4 14.7 18.7 14.5 19.15 14.5C19.6 14.5 20.1 14.7 20.1 14.7L20.3 13.5H20V13.5ZM24 19L23.1 13.5H22C21.7 13.5 21.45 13.7 21.35 13.95L19 19H20.5L20.8 18H22.7L22.9 19H24ZM21.2 17L22 14.5L22.45 17H21.2Z" fill="#ffe096"/>
</svg>
"""
/// Mastercard icon SVG
public static let mastercardIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<circle cx="13" cy="16" r="5" fill="#d68338"/>
<circle cx="19" cy="16" r="5" fill="#ffe096"/>
<path d="M16 12.5C17.1 13.4 17.8 14.6 17.8 16C17.8 17.4 17.1 18.6 16 19.5C14.9 18.6 14.2 17.4 14.2 16C14.2 14.6 14.9 13.4 16 12.5Z" fill="#fbcb74"/>
</svg>
"""
/// Amex card icon SVG
public static let amexIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<text x="16" y="18" text-anchor="middle" fill="#ffe096" font-size="8" font-weight="bold" font-family="Arial, sans-serif">AMEX</text>
</svg>
"""
/// Discover card icon SVG
public static let discoverIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="6" width="28" height="20" rx="3" fill="#f49541"/>
<circle cx="20" cy="16" r="4" fill="#ffe096"/>
<path d="M7 14H8.5C9.3 14 10 14.7 10 15.5C10 16.3 9.3 17 8.5 17H7V14Z" fill="#ffe096"/>
<rect x="11" y="14" width="1.5" height="3" fill="#ffe096"/>
<path d="M14 15C14 14.4 14.4 14 15 14C15.3 14 15.5 14.1 15.7 14.3L16.5 13.5C16.1 13.2 15.6 13 15 13C13.9 13 13 13.9 13 15C13 16.1 13.9 17 15 17C15.6 17 16.1 16.8 16.5 16.5L15.7 15.7C15.5 15.9 15.3 16 15 16C14.4 16 14 15.6 14 15Z" fill="#ffe096"/>
</svg>
"""
/// Note/document icon SVG
public static let noteIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 4C6.9 4 6 4.9 6 6V26C6 27.1 6.9 28 8 28H24C25.1 28 26 27.1 26 26V11L19 4H8Z" fill="#f49541"/>
<path d="M19 4V11H26L19 4Z" fill="#d68338"/>
<rect x="10" y="14" width="12" height="1.5" rx="0.75" fill="#ffe096"/>
<rect x="10" y="18" width="10" height="1.5" rx="0.75" fill="#ffe096"/>
<rect x="10" y="22" width="8" height="1.5" rx="0.75" fill="#ffe096"/>
</svg>
"""
/// Placeholder key icon SVG for Login/Alias without logo
public static let placeholderIcon = """
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="6.5" stroke="#f49541" stroke-width="2.5"/>
<circle cx="10" cy="10" r="2.5" stroke="#f49541" stroke-width="2"/>
<path d="M15 15L27 27" stroke="#f49541" stroke-width="2.5" stroke-linecap="round"/>
<path d="M19 19L23 15" stroke="#f49541" stroke-width="2.5" stroke-linecap="round"/>
<path d="M24 24L28 20" stroke="#f49541" stroke-width="2.5" stroke-linecap="round"/>
</svg>
"""
/// Get the appropriate SVG icon for a credit card brand
public static func getCardIcon(for brand: CardBrand) -> String {
switch brand {
case .visa:
return visaIcon
case .mastercard:
return mastercardIcon
case .amex:
return amexIcon
case .discover:
return discoverIcon
case .generic:
return creditCardIcon
}
}
/// Get the appropriate SVG icon for an item type
public static func getIcon(for itemType: ItemType, cardNumber: String? = nil) -> String {
switch itemType {
case .note:
return noteIcon
case .creditCard:
let brand = CardBrand.detect(from: cardNumber)
return getCardIcon(for: brand)
case .login, .alias:
return placeholderIcon
}
}
}

View File

@@ -25,7 +25,7 @@ public struct AutofillCredentialCard: View {
Button(action: action) {
HStack(spacing: 16) {
// Service logo
ServiceLogoView(logoData: credential.logo)
ItemLogoView(logoData: credential.logo)
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 4) {