Closed ChristopherGabba closed 5 months ago
@ChristopherGabba thanks for opening this issue! As you mentioned, this is a use case I don't think is efficiently achieved by the auto-generated queries. I think your best best will be to look into writing a custom resolver that performs a BatchGetItem to lookup multiple users by phone number.
Here's our docs on writing custom queries and mutations:
https://docs.amplify.aws/react/build-a-backend/data/custom-business-logic/
And heere's an example of what a AppSync JS resolver performing BatchGetItem
would look like:
A limitation to keep in mind with this approach is that, with BatchGetItem
, you can only retrieve up to 100 items at a time. So you will have to split up a user's contacts into batches of 100 and perform the query for each batch. If you request more than 100 items, or the response size exceeds 1MB per partition, the response will include an UnprocessedKeys
value which you can use in a subsequent query to get the rest of the items.
For more details on BatchGetItem, please refer to the DynamoDB docs: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchGetItem.html#:~:text=The%20BatchGetItem%20operation%20returns%20the,as%20many%20as%20100%20items.
@chrisbonifacio Awesome, I think this is what I'm looking for. I just read through your options and put together a start:
Per the first link, define a custom query:
UserBatchResponse: a.customType({
activeUsers: a.ref("User").array(),
}),
checkBatchOfPhoneNumbersForActiveUsers: a
.query()
.arguments({
phoneNumbers: a.string().array(),
})
.returns(a.ref("UserBatchResponse"))
.handler(a.handler.function(phoneBatchHandler))
.authorization((allow) => [allow.authenticated()]),
Per the second link, configuring the custom BatchGetItem
:
const phoneBatchHandler = defineFunction({
entry: "./phoneBatch-handler/handler.ts",
})
//phoneBatch-handler/handler.ts
import type { Schema } from '../resource'
import { util } from '@aws-appsync/utils'; //throwing error here as I do not have this as a dependendency
export const handler: Schema["User"]["type"][] = async (ctx) => {
const { phoneNumbers } = ctx.args;
return {
operation: 'BatchGetItem',
tables: {
users: [util.dynamodb.toMapValues({ phoneNumbers })]
}
};
};
I'm guessing I'll need to add @aws-appsync/utils
as a dependency to my app? Also I've never defined a custom function handler but in this case this seems too simple.
@ChristopherGabba yes, you have to install @aws-appsync/utils
in your project to use the dynamodb utility functions
Looks like the right idea in your resolver, let me know how it goes!
Hey @ChristopherGabba , just noticed that your resolver is a function but the utils and syntax are for AppSync JS resolvers. So you need to refactor a bit, for example your custom query's schema should look like this:
checkBatchOfPhoneNumbersForActiveUsers: a
.query()
.arguments({
phoneNumbers: a.string().array(),
})
.returns(a.ref("UserBatchResponse"))
.handler(
a.handler.custom({
dataSource: a.ref("User"),
entry: "./phoneBatchHandler.ts",
})
)
.authorization((allow) => [allow.authenticated()]),
and the resolver logic should look more like this, without the type annotation (which is for lambda resolvers):
import { Context, util } from "@aws-appsync/utils";
export const request = (ctx: Context) => {
const { phoneNumbers } = ctx.args;
return {
operation: "BatchGetItem",
tables: {
users: [util.dynamodb.toMapValues({ phoneNumbers })],
},
};
};
export const response = (ctx: Context) => {
return ctx.result;
};
I am now getting this error:
Cannot use `.ref()` to refer a model from a `custom type`. Field `activeUsers` of `UserBatchResponse` refers to model `User`
Caused By: Cannot use `.ref()` to refer a model from a `custom type`. Field `activeUsers` of `UserBatchResponse` refers to model `User`
According to the docs you can only use a.ref("") to reference enums and customTypes, so my User:
User: a
.model({
id: a.id().required(),
birthdate: a.string().required(),
firstName: a.string().required(),
lastName: a.string().required(),
username: a.string().required(),
phoneNumber: a.phone().required(),
pushToken: a.string(),
profileImage: a.url(),
profileImageBlurhash: a.string(),
searchTerm: a.string().required(),
sentFriendships: a.hasMany("Friendship", "senderId"),
receivedFriendships: a.hasMany("Friendship", "receiverId"),
})
.secondaryIndexes((index) => [
index("phoneNumber").queryField("listUsersByPhoneNumber"),
index("searchTerm").queryField("listUsersBySearchTerm").sortKeys(["id"]),
])
.authorization((allow) => [allow.owner(), allow.publicApiKey().to(["read"])]),
Can't be referenced through a ref
? How can I reference my User model without having to make a whole copy as a customType
?
I think the issue is that the model is nested in a custom type. You should use the model directly instead of the custom type.
try replacing .returns(a.ref("UserBatchResponse"))
with:
.returns(a.ref('User').array())
Okay, you nailed it. That fixed it and made it past the initial deployment, then it threw this error later on in the sandbox compilation:
amplify-reelfeel-christophergabba-sandbox-1be123c1b8-data7552DF31-NTFVC5Q3JJ65 | 4:17:19 PM | CREATE_FAILED | AWS::AppSync::FunctionConfiguration | data/Fn_Query_checkBatchOfPhoneNumbersForActiveUsers_1 (FnQuerycheckBatchOfPhoneNumbersForActiveUsers1) Resource handler returned message: "The code contains one or more errors. (Service: AppSync, Status Code: 400, Request ID: edc62a28-1b9b-4e96-bda1-ba9c087c5509)" (RequestToken: 3767e388-24d1-ecca-30fc-6331112e69ab, HandlerErrorCode: GeneralServiceException)
amplify-reelfeel-christophergabba-sandbox-1be123c1b8 | 4:17:29 PM | UPDATE_FAILED | AWS::CloudFormation::Stack | data.NestedStack/data.NestedStackResource (data7552DF31) Embedded stack arn:aws:cloudformation:us-east-1:440383253519:stack/amplify-reelfeel-christophergabba-sandbox-1be123c1b8-data7552DF31-NTFVC5Q3JJ65/fd79c3c0-0fa9-11ef-aba0-0e7d764f0719 was not successfully updated. Currently in UPDATE_ROLLBACK_IN_PROGRESS with reason: The following resource(s) failed to create: [FnQuerycheckBatchOfPhoneNumbersForActiveUsers1].
The CloudFormation deployment has failed.
Caused By: ❌ Deployment failed: Error: The stack named amplify-reelfeel-christophergabba-sandbox-1be123c1b8 failed to deploy: UPDATE_ROLLBACK_COMPLETE: Resource handler returned message: "The code contains one or more errors. (Service: AppSync, Status Code: 400, Request ID: edc62a28-1b9b-4e96-bda1-ba9c087c5509)" (RequestToken: 3767e388-24d1-ecca-30fc-6331112e69ab, HandlerErrorCode: GeneralServiceException), Embedded stack arn:aws:cloudformation:us-east-1:440383253519:stack/amplify-reelfeel-christophergabba-sandbox-1be123c1b8-data7552DF31-NTFVC5Q3JJ65/fd79c3c0-0fa9-11ef-aba0-0e7d764f0719 was not successfully updated. Currently in UPDATE_ROLLBACK_IN_PROGRESS with reason: The following resource(s) failed to create: [FnQuerycheckBatchOfPhoneNumbersForActiveUsers1].
Function:
checkBatchOfPhoneNumbersForActiveUsers: a
.query()
.arguments({
phoneNumbers: a.string().array(),
})
.returns(a.ref("User").array())
.handler(a.handler.custom({
dataSource: a.ref("User"),
entry: "./phoneBatchHandler.ts",
}))
.authorization((allow) => [allow.authenticated()]),
phoneBatchHander.ts
import { Context, util } from "@aws-appsync/utils";
export const request = (ctx: Context) => {
const { phoneNumbers } = ctx.args;
return {
operation: "BatchGetItem",
tables: {
users: [util.dynamodb.toMapValues({ phoneNumbers })],
},
};
};
export const response = (ctx: Context) => {
return ctx.result;
};
When I read this page: https://docs.amplify.aws/react/build-a-backend/data/custom-business-logic/ , it uses this synthax:
export const handler: Schema["echo"]["functionHandler"] =
In this case, I just have a request
and a response
instead of a handler
function, could that be why? It seems like it didn't like the phoneBatchHandler.ts
function.
Your resolver has the right logic, should have a request and response function for JS resolvers.
Handler functions are for Lambdas.
If you were to use a Lambda you would have to change the logic in your file from using AppSync's utils (which are built into AppSync JS resolvers) and instead use a DynamoDB client to perform the operation.
Try changing it to a js file rather than ts, removing the Context type import. I forgot AppSync JS resolvers only support JS, for TypeScript to work it has to be transpiled to JS
@chrisbonifacio Brilliant! That fixed the problem and it compiled. I would've spent 7 years and 400 ChatGPT attempts to figure that out, I'll try it out this afternoon on my lunch break and let you know if the query pulls data correctly.
@chrisbonifacio Okay I just tried out the query. I first created some fake users then I fed an array of random phone numbers where a few of the phone numbers were included.
The query returned null each time with the following error:
const testNumbers = ["+1111111111", "+12345678910", "+678910111213"]
const result = await client.queries.checkBatchOfPhoneNumbersForActiveUsers({
phoneNumbers: testNumbers,
})
console.log(result)
//logs: {"data": null, "errors": [{"data": null, "errorInfo": null, "errorType": "Code", "locations": [Array], "message": "Expected JSON object for '$[tables][users]' but got a 'ARRAY' instead.", "path": [Array]}]}
That's interesting, the error suggests there's a mismatch between the return type and what was returned from the resolver. The error says an ARRAY
was returned and the custom query expects to return an an array of User items.
I would console log the result from the resolver and enable logging in your API to see what data is being returned and if there's anything that needs to be adjusted about the return type of the custom query.
Oh! Actually, it might just be referring to this line in the resolver:
users: [util.dynamodb.toMapValues({ phoneNumbers })]
Try removing the array brackets so that it passes the map instead.
users: util.dynamodb.toMapValues({ phoneNumbers })
If that doesn't work, try converting the array to JSON like so:
users: JSON.stringify([util.dynamodb.toMapValues({ phoneNumbers })])
@chrisbonifacio This method without the array produced this error
users: util.dynamodb.toMapValues({ phoneNumbers })
//Error message
{"data": null, "errors": [{"data": null, "errorInfo": null, "errorType": "Code", "locations": [Array],
"message": "Unsupported element '$[tables][users][phoneNumbers]'.", "path": [Array]}]}
The stringify message produced:
users: JSON.stringify([util.dynamodb.toMapValues({ phoneNumbers })])
//Error message
{"data": null, "errors": [{"data": null, "errorInfo": null, "errorType": "Code", "locations": [Array], "message": "Expected JSON object for '$[tables][users]' but got a 'STRING' instead.", "path": [Array]}]
@ChristopherGabba So, while I figured out the correct syntax for the BatchGetItem operation from a JS resolver (our and AppSync's documentation needs updating), I learned that BatchGetItem only supports searching by primary keys. In this case, phoneNumber is not the primary key for a user so this approach won't work for this use case unfortunately.
for what it's worth, this is the proper syntax for a GetBatchItem JS resolver
import { util } from "@aws-appsync/utils";
export const request = (ctx) => {
const userIds = [];
ctx.args.userIds.forEach((userId) => {
users.push(util.dynamodb.toMapValues({ id: userId }));
});
return {
operation: "BatchGetItem",
tables: {
[ctx.env.USER_TABLE]: {
keys: userIds,
},
},
};
};
export const response = (ctx) => {
return ctx.result.data[ctx.env.USER_TABLE];
};
If I plug in phone Numbers instead of the primary key, I just an array of null
values and unprocessed keys:
If I pass a list of primary keys, id
, as arguments, I get data:
Now, there might still be a better way to do this, but as far as I am aware and with the current schema, I can only think of filtering for users with certain phone numbers using an OR filter expression. There is a limit to how large a filter expression can be though, the maximum length of an expression string being 4KB.
I was able to pass in around 95 phone numbers + two test numbers in one query:
const checkActivePhoneNumbers = async () => {
const largeArray = new Array(95).fill("");
const fakePhoneNumbers = largeArray.map((_, index) => `${index}`);
const phoneNumbers = ["718-706-5432", "718-706-4327", ...fakePhoneNumbers];
const { data, errors } = await client.models.User.list({
filter: {
or: phoneNumbers.map((phoneNumber) => {
return {
phoneNumber: {
eq: phoneNumber,
},
};
}),
},
authMode: "apiKey",
});
console.log(data);
setUsers(data);
};
@chrisbonifacio thank you for investigating and coming to a consensus! Let me ask you this: do you think if I made the primary key the phone number, this method would work? I noticed you can also define multiple identifiers so perhaps listing ID and phoneNumber or both?
Also the problem I see with your filter approach is if you have say 100,000 users and you are now using a scan operation. So you would only scan whichever the initial limit setting in the query is at. You may need 50 next tokens to fully check all the users for a matching phone number,or you need to set the limit to 100000000 (large number). Without searching all users for a matching phone number, you risk telling the client that there is no user with that phone number which could create some confusion.
Yeah, while you could potentially paginate the filtered query, the approach is still inefficient and could still result in no existing users being found.
There are privacy concerns working with phone numbers as primary keys so that's up to you. One option is to hash them which would require some processing during entry and querying to compare the hash values while obfuscating the real values.
Also, a user's phone number can change so that would be something to consider as well.
In any case, if you can store phone numbers as the primary key that would allow you to use GetBatchItem.
Using the phone number to make a composite key is also a valid approach, just need to consider that you would need to know both a user's id and phone number ahead of time. This is a problem because a user's contacts only provides a phone number and you'd have to do the extra work of finding the id that matches the phone number anyway.
Another approach might be to create a separate table for storing phone numbers as the primary key and perhaps other user profile info. This table can have a hasOne relationship from User to Profile/ContactInfo/etc and this new table can have a belongsTo relationship to a User record.
Again, not sure what the best approach is here but these are options to consider.
I mention this approach because it's not uncommon for customers to want to use a user's Cognito sub as the value for an id
primary key.
Thank you @chrisbonifacio, I like your second approach so I tried it out:
//schema
User: a
.model({
id: a.id().required(),
birthdate: a.string().required(),
firstName: a.string().required(),
lastName: a.string().required(),
username: a.string().required(),
phoneNumber: a.hasOne("PhoneNumber", "userId"),
pushToken: a.string(),
profileImage: a.url(),
profileImageBlurhash: a.string(),
searchTerm: a.string().required(),
sentFriendships: a.hasMany("Friendship", "senderId"),
receivedFriendships: a.hasMany("Friendship", "receiverId"),
})
.secondaryIndexes((index) => [
index("searchTerm").queryField("listUsersBySearchTerm").sortKeys(["id"]),
])
.authorization((allow) => [allow.publicApiKey()]),
PhoneNumber: a
.model({
phoneNumber: a.string().required(),
userId: a.string().required(),
user: a.belongsTo("User", "userId"),
})
.identifier(["phoneNumber"])
.authorization((allow) => [allow.publicApiKey()]),
checkBatchOfPhoneNumbersForActiveUsers: a
.query()
.arguments({
phoneNumbers: a.string().array(),
})
.returns(a.ref("PhoneNumber").array())
.handler(
a.handler.custom({
dataSource: a.ref("PhoneNumber"),
entry: "./phoneBatchHandler.js",
}),
)
.authorization((allow) => [allow.publicApiKey()]),
// phoneHandler.js
import { util } from "@aws-appsync/utils";
export const request = (ctx) => {
const phoneNumbers = [];
ctx.args.phoneNumbers.forEach((phoneNumber) => {
phoneNumbers.push(util.dynamodb.toMapValues({ id: phoneNumber }));
});
return {
operation: "BatchGetItem",
tables: {
[ctx.env.PHONENUMBER_TABLE]: {
keys: phoneNumbers,
},
},
};
};
export const response = (ctx) => {
return ctx.result.data[ctx.env.PHONENUMBER_TABLE];
};
It compiled but now I'm getting this error:
const client = generateClient<Schema>()
const testNumbers = ["+14323496006", "+19722367519", "+678910111213"]
const result = await client.queries.checkBatchOfPhoneNumbersForActiveUsers({
phoneNumbers: testNumbers,
})
console.log(result)
//logs {"data": null, "errors": [{"data": null, "errorInfo": null, "errorType": "Code", "locations": [Array], "message": "Runtime Error", "path": [Array]}]}
So there appears to be some sort of error in my logic. Because typescript doesn't infer anything in the phoneBatchHandler function, I'm not positive if the env.PHONENUMBER_TABLE
is correct.
Also because I want to be able to deep reference the user like so results.data[0].user.firstName
, how can you define a selectionSet for the custom query so that all user data returns in the phone number object?
This line should just be { phoneNumber })
because it's the primary key rather than id: phoneNumber
ctx.args.phoneNumbers.forEach((phoneNumber) => {
phoneNumbers.push(util.dynamodb.toMapValues({ phoneNumber }));
})
As for the environment variable, how are you setting it in your backend.ts file?
This is all I have in the backend.ts
const schema = a.schema({
User: a
.model({
id: a.id().required(),
birthdate: a.string().required(),
firstName: a.string().required(),
lastName: a.string().required(),
username: a.string().required(),
phoneNumber: a.hasOne("PhoneNumber", "userId"),
pushToken: a.string(),
profileImage: a.url(),
profileImageBlurhash: a.string(),
searchTerm: a.string().required(),
})
.secondaryIndexes((index) => [
index("searchTerm").queryField("listUsersBySearchTerm").sortKeys(["id"]),
])
.authorization((allow) => [allow.publicApiKey()]),
PhoneNumber: a
.model({
phoneNumber: a.string().required(),
userId: a.string().required(),
user: a.belongsTo("User", "userId"),
})
.identifier(["phoneNumber"])
.authorization((allow) => [allow.publicApiKey()]),
checkBatchOfPhoneNumbersForActiveUsers: a
.query()
.arguments({
phoneNumbers: a.string().array(),
})
.returns(a.ref("PhoneNumber").array())
.handler(
a.handler.custom({
dataSource: a.ref("PhoneNumber"),
entry: "./phoneBatchHandler.js",
}),
)
.authorization((allow) => [allow.publicApiKey()]),
})
export type Schema = ClientSchema<typeof schema>
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: "apiKey",
apiKeyAuthorizationMode: { expiresInDays: 30 },
},
})
I don't think I specify anything specific as far as environment variables go.
Ah, that might be the issue then. You can set the environment variable similar to this in the backend.ts
file:
backend.data.resources.cfnResources.cfnGraphqlApi.environmentVariables = {
PHONENUMBER_TABLE: "<insert-table-name>",
};
The environment variables set here will be available to all AppSync resolvers via the context's env
object, which is why the resolver logic uses ctx.env
rather than process.env
.
We append some stuff like the amplify appId to the table name so you'll probably have to find the correct table name by searching "PhoneNumber" in the DynamoDB console
just tested it and I also got the runtime error if I try to access an env var that isn't set on the API.
Setting it resulted in a working batch query:
@chrisbonifacio Here is my code: backend.data.resources.cfnResources.cfnGraphqlApi.environmentVariables = { PHONENUMBER_TABLE: "PhoneNumber-6ds57ytuajcg7gq76fizgkx2pe-NONE", };
Here is my number:
It seems to no longer be throwing an error! But now I'm getting:
{"data": {"0": null, "user": [Function anonymous]}, "extensions": undefined}
When I lazy load the user it returns null
. I'm not so sure why there even is auser
in the result like that given that it should be a phoneNumber
array.
I'm going to try deleting my deployment and these tables. For some reason it keeps making duplicate dynamoDB tables and try again.
Yeah, there's currently a bug with the way data is deserialized that we caught yesterday in a different scenario. The data should be in the form of an array, not an object. And there should be a user
function for each PhoneNumber record in the array to lazy load the relationships.
I also tried looking into the selectionSet
but it seems that custom queries/mutations don't currently support custom selection sets. So, since lazy loading would've been the workaround, you'd have to drop down to the client.graphql
API as a workaround unfortunately.
to generate the qraphql statements you can use the command:
npx ampx generate graphql-client-code --out src/graphql
In the graphql/queries.ts
file you should see the custom query with a selection set that includes the user:
query CheckBatchOfPhoneNumbersForActiveUsers($phoneNumbers: [String]) {
checkBatchOfPhoneNumbersForActiveUsers(phoneNumbers: $phoneNumbers) {
createdAt
phoneNumber
updatedAt
user {
birthdate
createdAt
firstName
id
lastName
profileImage
profileImageBlurhash
pushToken
searchTerm
updatedAt
username
__typename
}
userId
__typename
}
}
Here's an example I tested of what the client.graphql
query would look like:
//...
import { checkBatchOfPhoneNumbersForActiveUsers } from "@/src/graphql/queries";
//...
const checkActivePhoneNumbersWithGraphQL = async () => {
const largeArray = new Array(95).fill("");
const fakePhoneNumbers = largeArray.map((_, index) => `${index}`);
const phoneNumbersToCheckFor = [
"718-706-5432",
"718-706-4327",
...fakePhoneNumbers,
];
let {
data: { checkBatchOfPhoneNumbersForActiveUsers: phoneNumbers },
} = await client.graphql({
query: checkBatchOfPhoneNumbersForActiveUsers,
variables: {
phoneNumbers: phoneNumbersToCheckFor,
},
});
console.log(phoneNumbers);
};
result (in an array as expected with User data nested):
To those who stumble across this thread this is the exact solution I implemented and finally got the deployment working thanks to @chrisbonifacio:
User
schema into a User
schema with a PhoneNumber
schema relationship to expose phoneNumber as a primary key: User: a
.model({
id: a.id().required(),
birthdate: a.string().required(),
firstName: a.string().required(),
lastName: a.string().required(),
username: a.string().required(),
phoneNumber: a.hasOne("PhoneNumber", "userId"),
pushToken: a.string(),
profileImage: a.url(),
profileImageBlurhash: a.string(),
searchTerm: a.string().required(),
})
.secondaryIndexes((index) => [
index("searchTerm").queryField("listUsersBySearchTerm").sortKeys(["id"]),
])
.authorization((allow) => [allow.publicApiKey()]),
PhoneNumber: a
.model({
phoneNumber: a.string().required(),
userId: a.string().required(),
user: a.belongsTo("User", "userId"),
})
.identifier(["phoneNumber"])
.authorization((allow) => [allow.publicApiKey()]),
checkBatchOfPhoneNumbersForActiveUsers: a
.query()
.arguments({
phoneNumbers: a.string().array(),
})
.returns(a.ref("PhoneNumber").array())
.handler(
a.handler.custom({
dataSource: a.ref("PhoneNumber"),
entry: "./phoneBatchHandler.js",
}),
)
.authorization((allow) => [allow.publicApiKey()]),
backend.ts
file, define your PhoneNumber table:
const backend = defineBackend({
auth,
data,
storage,
})
// Define Phone Table variable for batch query
backend.data.resources.cfnResources.cfnGraphqlApi.environmentVariables = {
PHONENUMBER_TABLE: "PhoneNumber-abcdefghijklmnop-NONE",
};
3. Here is the `phoneBatchHander.js` file (MAKE SURE THIS FILE IS NOT `ts` and is a javascript file `js`:
```typescript
import { util } from "@aws-appsync/utils";
export const request = (ctx) => {
const phoneNumbers = [];
ctx.args.phoneNumbers.forEach((phoneNumber) => {
phoneNumbers.push(util.dynamodb.toMapValues({ phoneNumber }));
});
return {
operation: "BatchGetItem",
tables: {
[ctx.env.PHONENUMBER_TABLE]: {
keys: phoneNumbers,
},
},
};
};
export const response = (ctx) => {
return ctx.result.data[ctx.env.PHONENUMBER_TABLE];
};
and finally as of right now, in order to expose the deeper user object
from the PhoneNumber batch query results refer to @chrisbonifacio's cli function above. I submitted a feature request in order to define selection sets within this function and remove this requirement.
Closing issue (finally)! Thanks again @chrisbonifacio
Before opening, please confirm:
JavaScript Framework
React Native
Amplify APIs
GraphQL API
Amplify Version
v6
Amplify Categories
api
Backend
Amplify Gen 2 (Preview)
Environment information
Describe the bug
Not so much a bug, but a question I can't seem to find much data on.
Here is my sequence:
The problem is that secondary index queries only allow me to do a database query for a single phone number:
My function:
This would mean that I would need to have a for loop that queries 500 times to the database to check if there is a user with that phone number. At scale, this seems super expensive. I'm guessing there is a better way... Is there a way to make a custom query that takes the whole array of phone numbers?
What is the best way to accomplish this?
Expected behavior
N/A
Reproduction steps
N/A
Code Snippet
See above.
Log output
aws-exports.js
No response
Manual configuration
No response
Additional configuration
No response
Mobile Device
iPhone 12 Physical
Mobile Operating System
iOS 17
Mobile Browser
No response
Mobile Browser Version
No response
Additional information and screenshots
No response