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