001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.Manifest;
007import android.content.ActivityNotFoundException;
008import android.content.ContentValues;
009import android.content.Context;
010import android.content.Intent;
011import android.content.pm.PackageManager;
012import android.database.Cursor;
013import android.net.Uri;
014import android.provider.ContactsContract;
015import android.util.Log;
016
017import org.json.JSONArray;
018import org.json.JSONException;
019import org.json.JSONObject;
020
021import java.util.ArrayList;
022
023/**
024 * The people who may reach you during do-not-disturb, as this phone's
025 * contacts have them (crates/sioul-app/src/everywhere.rs): Android lets only
026 * starred contacts through a mode, so Sioul says who on the list is not
027 * starred here, and opens their contact for you to star it. Reading only
028 * (READ_CONTACTS): Sioul never writes a star nor a contact; "Add to
029 * contacts" opens the Contacts app's own form, filled, which you save.
030 */
031final class DndContacts
032{
033    private DndContacts() {}
034
035    static boolean allowed(Context context)
036    {
037        return context.checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED;
038    }
039
040    /**
041     * {people: [{id, phones}]} → {permission, people: [{id, state, contact}]}:
042     * "starred" when a contact with one of their numbers is starred,
043     * "not-starred" when one has a number but none is starred (its address
044     * to open), "unknown" when no contact has their numbers.
045     */
046    static JSONObject stars(Context context, JSONObject asked) throws JSONException
047    {
048        JSONObject answer = new JSONObject();
049        if (!allowed(context)) {
050            answer.put("permission", false);
051            return answer;
052        }
053        answer.put("permission", true);
054        JSONArray found = new JSONArray();
055        JSONArray people = asked.optJSONArray("people");
056        for (int i = 0; people != null && i < people.length(); i++) {
057            JSONObject person = people.getJSONObject(i);
058            JSONArray phones = person.optJSONArray("phones");
059            String state = "unknown";
060            String contact = "";
061            for (int j = 0; phones != null && j < phones.length() && !"starred".equals(state); j++) {
062                String number = phones.optString(j, "").trim();
063                if (number.isEmpty())
064                    continue;
065                Uri lookup = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number));
066                String[] columns = { ContactsContract.PhoneLookup._ID, ContactsContract.PhoneLookup.LOOKUP_KEY, ContactsContract.PhoneLookup.STARRED };
067                try (Cursor rows = context.getContentResolver().query(lookup, columns, null, null, null)) {
068                    while (rows != null && rows.moveToNext()) {
069                        long id = rows.getLong(0);
070                        String key = rows.getString(1);
071                        boolean starred = rows.getInt(2) != 0;
072                        if (starred) {
073                            state = "starred";
074                            contact = "";
075                            break;
076                        }
077                        state = "not-starred";
078                        if (contact.isEmpty() && key != null)
079                            contact = ContactsContract.Contacts.getLookupUri(id, key).toString();
080                    }
081                } catch (RuntimeException e) {
082                    Log.w(DoseAlarms.TAG, "Do not disturb: a number not looked up: " + e);
083                }
084            }
085            JSONObject seen = new JSONObject();
086            seen.put("id", person.optString("id"));
087            seen.put("state", state);
088            seen.put("contact", contact);
089            found.put(seen);
090        }
091        answer.put("people", found);
092        return answer;
093    }
094
095    /** A contact opened in the Contacts app, where its star is. */
096    static boolean open(Context context, String uri)
097    {
098        if (uri.isEmpty() || !uri.startsWith(ContactsContract.Contacts.CONTENT_LOOKUP_URI.toString()))
099            return false;
100        try {
101            context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(uri)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
102            return true;
103        } catch (ActivityNotFoundException | SecurityException e) {
104            Log.w(DoseAlarms.TAG, "Do not disturb: no app to open a contact: " + e);
105            return false;
106        }
107    }
108
109    /** The Contacts app's form for a new contact, filled: {name, phones, emails}. Saved by you, or not. */
110    static boolean add(Context context, JSONObject person)
111    {
112        Intent form = new Intent(ContactsContract.Intents.Insert.ACTION).setType(ContactsContract.RawContacts.CONTENT_TYPE);
113        String name = person.optString("name", "");
114        if (!name.isEmpty())
115            form.putExtra(ContactsContract.Intents.Insert.NAME, name);
116        ArrayList<ContentValues> data = new ArrayList<>();
117        JSONArray phones = person.optJSONArray("phones");
118        for (int i = 0; phones != null && i < phones.length(); i++) {
119            ContentValues row = new ContentValues();
120            row.put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE);
121            row.put(ContactsContract.CommonDataKinds.Phone.NUMBER, phones.optString(i));
122            row.put(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE);
123            data.add(row);
124        }
125        JSONArray emails = person.optJSONArray("emails");
126        for (int i = 0; emails != null && i < emails.length(); i++) {
127            ContentValues row = new ContentValues();
128            row.put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE);
129            row.put(ContactsContract.CommonDataKinds.Email.ADDRESS, emails.optString(i));
130            row.put(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_OTHER);
131            data.add(row);
132        }
133        form.putParcelableArrayListExtra(ContactsContract.Intents.Insert.DATA, data);
134        try {
135            context.startActivity(form.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
136            return true;
137        } catch (ActivityNotFoundException | SecurityException e) {
138            Log.w(DoseAlarms.TAG, "Do not disturb: no Contacts app to add a contact: " + e);
139            return false;
140        }
141    }
142}