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}