diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2418812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/obj/ +/bin/ +/.vs/ +*.csproj.user diff --git a/Assets/AboutAssets.txt b/Assets/AboutAssets.txt new file mode 100644 index 0000000..2f59e2c --- /dev/null +++ b/Assets/AboutAssets.txt @@ -0,0 +1,19 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories) and given a Build Action of "AndroidAsset". + +These files will be deployed with your package and will be accessible using Android's +AssetManager, like this: + +public class ReadAsset : Activity +{ + protected override void OnCreate (Bundle bundle) + { + base.OnCreate (bundle); + + InputStream input = Assets.Open ("my_asset.txt"); + } +} + +Additionally, some Android functions will automatically load asset files: + +Typeface tf = Typeface.CreateFromAsset (Context.Assets, "fonts/samplefont.ttf"); \ No newline at end of file diff --git a/Call2Push.csproj b/Call2Push.csproj new file mode 100644 index 0000000..1086668 --- /dev/null +++ b/Call2Push.csproj @@ -0,0 +1,188 @@ + + + + Debug + AnyCPU + 8.0.30703 + 2.0 + {74CF076E-0C22-4621-9F83-CD5981DD12A0} + {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + {84dd83c5-0fe3-4294-9419-09e7c8ba324f} + Library + Properties + SilentOrbit + Call2Push + 512 + True + Resources\Resource.designer.cs + Resource + Off + false + v8.0 + Properties\AndroidManifest.xml + Resources + Assets + true + true + Xamarin.Android.Net.AndroidClientHandler + false + + + + + True + portable + False + bin\Debug\ + DEBUG;TRACE + prompt + 4 + True + None + False + false + false + false + false + + + false + portable + True + bin\Release\ + TRACE + prompt + 4 + true + False + Full + true + false + false + false + false + + false + apk + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 12.0.3 + + + + + + + + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + MSBuild:UpdateGeneratedFiles + Designer + + + + + \ No newline at end of file diff --git a/Call2Push.sln b/Call2Push.sln new file mode 100644 index 0000000..ce5c0e0 --- /dev/null +++ b/Call2Push.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29418.71 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Call2Push", "Call2Push.csproj", "{74CF076E-0C22-4621-9F83-CD5981DD12A0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {74CF076E-0C22-4621-9F83-CD5981DD12A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74CF076E-0C22-4621-9F83-CD5981DD12A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74CF076E-0C22-4621-9F83-CD5981DD12A0}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {74CF076E-0C22-4621-9F83-CD5981DD12A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74CF076E-0C22-4621-9F83-CD5981DD12A0}.Release|Any CPU.Build.0 = Release|Any CPU + {74CF076E-0C22-4621-9F83-CD5981DD12A0}.Release|Any CPU.Deploy.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {19C5AD54-D71A-48D2-A92C-F13C7EE777EA} + EndGlobalSection +EndGlobal diff --git a/CallReceiver.cs b/CallReceiver.cs new file mode 100644 index 0000000..b190dd5 --- /dev/null +++ b/CallReceiver.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using Android.App; +using Android.Content; +using Android.OS; +using Android.Runtime; +using Android.Telephony; +using Android.Views; +using Android.Widget; +using SilentOrbit.LocalData; +using SilentOrbit.Pipedrive; +using SilentOrbit.Pushbullet; + +namespace SilentOrbit +{ + [BroadcastReceiver(Enabled = true)] + [IntentFilter(new[] { "android.intent.action.PHONE_STATE" })] + public class CallReceiver : BroadcastReceiver + { + public override void OnReceive(Context context, Intent intent) + { + var ts = new ContextWrapper(context).GetSystemService(Context.TelephonyService); + var tm = (TelephonyManager)ts; + + if (tm.CallState != CallState.Ringing) + return; + + string number = intent.GetStringExtra(TelephonyManager.ExtraIncomingNumber); + + if (string.IsNullOrEmpty(number)) + return; + + //Remove ending "***" meaning phone number was to the main number + number = number.TrimEnd('*'); + + number = PipedriveStorage.Normalize(number); + + var url = FindUrl(context, number); + if (url != null) + PushbulletAPI.Push(url); + } + + string FindUrl(Context context, string number) + { + //Android Contacts: note with prefix: "call2push:" or "c2p:" + var url = ContactsLookup.Lookup(context, number); + if (url != null) + return url; + + //Pipedrive + url = PipedriveStorage.Lookup(number); + if (url != null) + return url; + + + return null; + } + } +} \ No newline at end of file diff --git a/Contacts/ContactsLookup.cs b/Contacts/ContactsLookup.cs new file mode 100644 index 0000000..5701142 --- /dev/null +++ b/Contacts/ContactsLookup.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Android.App; +using Android.Content; +using Android.Database; +using Android.OS; +using Android.Provider; +using Android.Runtime; +using Android.Views; +using Android.Widget; +using static Android.Provider.ContactsContract; +using static Android.Provider.ContactsContract.CommonDataKinds; +using Uri = Android.Net.Uri; + +namespace SilentOrbit +{ + /// + /// Scan Android Contacts for notes with the prefix: "call2push:" or "c2p:" + /// followed by the url to push + /// + class ContactsLookup + { + readonly Context context; + + ContactsLookup(Context context) + { + this.context = context; + } + + /// + /// Find notes in contacts with prefix: "call2push:" or "c2p:" + /// + /// + /// + public static string Lookup(Context context, string number) + { + return new ContactsLookup(context).PhoneLookup(number); + } + + string DebugColumns(ICursor cur) + { + string cols = ""; + + var colNames = cur.GetColumnNames(); + for (var i = 0; i < colNames.Length; i++) + { + var val = cur.GetString(i); + cols += colNames[i] + ": " + val + "\n"; + } + Console.WriteLine(cols); + return cols; + } + + string PhoneLookup(string number) + { + var contentResolver = context.ContentResolver; + using (var cursor = contentResolver.Query( + uri: Uri.WithAppendedPath(ContactsContract.PhoneLookup.ContentFilterUri, Uri.Encode(number)), + projection: new[] { PhoneLookupColumns.ContactId }, + selection: null, + selectionArgs: null, + sortOrder: null)) + { + while (cursor.MoveToNext()) + { + //var cols = DebugColumns(cursor); + + //Looks like this contactID is the same as raw_contact_id + var contactID = cursor.GetString(cursor.GetColumnIndex("contact_id")); + var url = GetNoteUrl(contactID); + if (url != null) + return url; + } + } + return null; + } + + string GetNoteUrl(string contactID) + { + var contentResolver = context.ContentResolver; + using (var cursor = contentResolver.Query( + uri: ContactsContract.Data.ContentUri, + projection: new[] { ContactsContract.DataColumns.Data1 }, //Notes + selection: "contact_id = ?", + selectionArgs: new[] { contactID }, + sortOrder: null)) + { + string note = ""; + while (cursor.MoveToNext()) + { + note += cursor.GetString(cursor.GetColumnIndex(ContactsContract.DataColumns.Data1)) + "\n"; + } + return ParseNote(note); + } + } + + static readonly Regex noteLink = new Regex("c(all)?2p(ush)?: *([^ ]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + string ParseNote(string note) + { + var lines = note.Split('\n', '\r'); + foreach (var l in lines) + { + var m = noteLink.Match(l); + if (m.Success) + return m.Groups[3].Value; + } + + return null; + } + + } +} \ No newline at end of file diff --git a/LocalData/C2PConfig.cs b/LocalData/C2PConfig.cs new file mode 100644 index 0000000..ed13244 --- /dev/null +++ b/LocalData/C2PConfig.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Android.App; +using Android.Content; +using Android.OS; +using Android.Runtime; +using Android.Views; +using Android.Widget; +using Newtonsoft.Json; +using SilentOrbit.Pipedrive; + +namespace SilentOrbit.LocalData +{ + /// + /// Local saved keys + /// + class C2PConfig + { + public static C2PConfig Instance { get; private set; } = new C2PConfig(); + + public string PipedriveKey { get; set; } + public string PushbulletKey { get; set; } + public string PushbulletDevice { get; set; } + + static readonly string path = Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments), "config.json"); + + static C2PConfig() + { + if (File.Exists(path)) + { + var jsonRead = File.ReadAllText(path); + Instance = JsonConvert.DeserializeObject(jsonRead); + } + } + + public void Save() + { + var json = JsonConvert.SerializeObject(this); + File.WriteAllText(path, json); + } + } +} \ No newline at end of file diff --git a/LocalData/PipedriveStorage.cs b/LocalData/PipedriveStorage.cs new file mode 100644 index 0000000..1045f40 --- /dev/null +++ b/LocalData/PipedriveStorage.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Android.App; +using Android.Content; +using Android.OS; +using Android.Runtime; +using Android.Support.V4.App; +using Android.Views; +using Android.Widget; +using Newtonsoft.Json; +using SilentOrbit.Pipedrive; + +namespace SilentOrbit.LocalData +{ + class PipedriveStorage + { + static PipedriveStorage instance; + + public Dictionary Phone2ID { get; set; } = new Dictionary(); + + static readonly string path = Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments), "contacts.json"); + + public enum LoadMode + { + /// + /// Load from disk only + /// + Quick, + /// + /// Load from disk or internet + /// + Full, + /// + /// Reload from internet + /// + Reload, + } + + static Mutex mutex = new Mutex(); + + public static int Load(LoadMode mode) + { + if (mutex.WaitOne(1)) + { + try + { + LoadInternal(mode); + return instance.Phone2ID.Count; + } + finally + { + mutex.ReleaseMutex(); + } + } + else + return -1; + } + + static void LoadInternal(LoadMode mode) + { + if (mode != LoadMode.Reload) + { + if (instance != null) + return; + + if (File.Exists(path)) + { + var jsonRead = File.ReadAllText(path); + instance = JsonConvert.DeserializeObject(jsonRead); + + if (instance.Phone2ID != null) + return; + } + } + + if (mode == LoadMode.Quick) + return; + + //Load from API + var inst = new PipedriveStorage(); + inst.Phone2ID = PipedriveAPI.LoadAllPersons(); + instance = inst; + + //Save + var jsonWrite = JsonConvert.SerializeObject(instance); + File.WriteAllText(path, jsonWrite); + } + + public static string Lookup(string key) + { + try + { + Load(LoadMode.Quick); + } + catch + { + + } + + if (instance == null) + return null; + + var number = Normalize(key); + if (number == null) + return null; + + if (instance.Phone2ID.TryGetValue(number, out var id)) + return "https://murbox.pipedrive.com/person/" + id; + else + return null; + } + + static readonly Regex notNumbers = new Regex("[^\\+\\d]", RegexOptions.Compiled); + + public static string Normalize(string value) + { + if (string.IsNullOrEmpty(value)) + return null; + + var number = notNumbers.Replace(value, ""); + + if (number == "") + return number; + + if (number.StartsWith("+")) + { + return number; + } + else + { + //Missing country prefix + //Assume +46 + + if (number.StartsWith("0")) + { + return "+46" + number.Substring(1); + } + else + { + if (number.Length < 8) + return number; + + return "+46" + number; + } + } + + } + + } +} \ No newline at end of file diff --git a/MainActivity.cs b/MainActivity.cs new file mode 100644 index 0000000..8f5f760 --- /dev/null +++ b/MainActivity.cs @@ -0,0 +1,161 @@ +using System; +using System.Linq; +using Android; +using Android.App; +using Android.Content; +using Android.OS; +using Android.Widget; +using SilentOrbit.LocalData; +using SilentOrbit.Pushbullet; + +namespace SilentOrbit +{ + [Activity(Label = "@string/app_name", MainLauncher = true)] + public class MainActivity : Activity + { + readonly string[] requiredPermissions = new[] { + Manifest.Permission.ReadContacts, //read url from address book + Manifest.Permission.ReadPhoneState, //Get incoming calls + Manifest.Permission.ReadPhoneNumbers, //Get incoming phone number + Manifest.Permission.ReadCallLog,//Get incoming phone number + }; + + TextView pipedriveStatusView; + + protected override void OnCreate(Bundle savedInstanceState) + { + base.OnCreate(savedInstanceState); + Xamarin.Essentials.Platform.Init(this, savedInstanceState); + SetContentView(Resource.Layout.activity_main); + + FindViewById