From 3b50ff10da109a6f70b0ad3cd0cfd54e6f78c994 Mon Sep 17 00:00:00 2001 From: Mark Bates Date: Fri, 31 Jul 2009 18:04:00 -0400 Subject: [PATCH] Nearly done with feedback. Just adding documentation. --- README | 8 +++ README.textile | 9 +++ Rakefile | 2 +- apn_on_rails.gemspec | 6 +- generators/apn_migrations_generator.rb | 10 ++-- .../003_add_registered_at_to_apn_devices.rb | 22 +++++++ lib/apn_on_rails/app/models/apn/base.rb | 9 +++ lib/apn_on_rails/app/models/apn/device.rb | 17 +++++- .../app/models/apn/notification.rb | 4 +- .../{app/models/apn => libs}/connection.rb | 0 lib/apn_on_rails/libs/feedback.rb | 42 ++++++++++++++ lib/apn_on_rails/tasks/apn.rake | 9 +++ .../app/models/apn/device_spec.rb | 17 ++++++ .../app/models/apn/notification_spec.rb | 2 +- .../models/apn => libs}/connection_spec.rb | 6 +- spec/apn_on_rails/libs/feedback_spec.rb | 57 +++++++++++++++++++ spec/spec_helper.rb | 3 + 17 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 generators/templates/apn_migrations/003_add_registered_at_to_apn_devices.rb create mode 100644 lib/apn_on_rails/app/models/apn/base.rb rename lib/apn_on_rails/{app/models/apn => libs}/connection.rb (100%) create mode 100644 lib/apn_on_rails/libs/feedback.rb rename spec/apn_on_rails/{app/models/apn => libs}/connection_spec.rb (90%) create mode 100644 spec/apn_on_rails/libs/feedback_spec.rb diff --git a/README b/README index 7c0f4139..a9028f73 100644 --- a/README +++ b/README @@ -84,6 +84,14 @@ see fit: That's it, now you're ready to start creating notifications. +===Upgrade Notes: + +If you are upgrading to a new version of APN on Rails you should always run: + + $ ruby script/generate apn_migrations + +That way you ensure you have the latest version of the database tables needed. + ==Example: $ ./script/console diff --git a/README.textile b/README.textile index 4f9be011..00802008 100644 --- a/README.textile +++ b/README.textile @@ -94,6 +94,15 @@ see fit: That's it, now you're ready to start creating notifications. +h3. Upgrade Notes: + +If you are upgrading to a new version of APN on Rails you should always run: +

+  $ ruby script/generate apn_migrations
+
+ +That way you ensure you have the latest version of the database tables needed. + h2. Example:

diff --git a/Rakefile b/Rakefile
index 5ba06737..a075274a 100644
--- a/Rakefile
+++ b/Rakefile
@@ -4,7 +4,7 @@ require 'gemstub'
 Gemstub.test_framework = :rspec
 
 Gemstub.gem_spec do |s|
-  s.version = "0.2.2"
+  s.version = "0.3.0"
   s.rubyforge_project = "magrathea"
   s.add_dependency('configatron')
   s.email = 'mark@markbates.com'
diff --git a/apn_on_rails.gemspec b/apn_on_rails.gemspec
index 3cc78173..436ed17e 100644
--- a/apn_on_rails.gemspec
+++ b/apn_on_rails.gemspec
@@ -2,15 +2,15 @@
 
 Gem::Specification.new do |s|
   s.name = %q{apn_on_rails}
-  s.version = "0.2.2.20090730143010"
+  s.version = "0.3.0.20090731174823"
 
   s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
   s.authors = ["markbates"]
-  s.date = %q{2009-07-30}
+  s.date = %q{2009-07-31}
   s.description = %q{apn_on_rails was developed by: markbates}
   s.email = %q{mark@markbates.com}
   s.extra_rdoc_files = ["README", "LICENSE"]
-  s.files = ["lib/apn_on_rails/apn_on_rails.rb", "lib/apn_on_rails/app/models/apn/connection.rb", "lib/apn_on_rails/app/models/apn/device.rb", "lib/apn_on_rails/app/models/apn/notification.rb", "lib/apn_on_rails/tasks/apn.rake", "lib/apn_on_rails/tasks/db.rake", "lib/apn_on_rails.rb", "lib/apn_on_rails_tasks.rb", "README", "LICENSE", "generators/apn_migrations_generator.rb", "generators/templates/apn_migrations/001_create_apn_devices.rb", "generators/templates/apn_migrations/002_create_apn_notifications.rb"]
+  s.files = ["lib/apn_on_rails/apn_on_rails.rb", "lib/apn_on_rails/app/models/apn/base.rb", "lib/apn_on_rails/app/models/apn/device.rb", "lib/apn_on_rails/app/models/apn/notification.rb", "lib/apn_on_rails/libs/connection.rb", "lib/apn_on_rails/libs/feedback.rb", "lib/apn_on_rails/tasks/apn.rake", "lib/apn_on_rails/tasks/db.rake", "lib/apn_on_rails.rb", "lib/apn_on_rails_tasks.rb", "README", "LICENSE", "generators/apn_migrations_generator.rb", "generators/templates/apn_migrations/001_create_apn_devices.rb", "generators/templates/apn_migrations/002_create_apn_notifications.rb", "generators/templates/apn_migrations/003_add_registered_at_to_apn_devices.rb"]
   s.homepage = %q{http://www.metabates.com}
   s.require_paths = ["lib"]
   s.rubyforge_project = %q{magrathea}
diff --git a/generators/apn_migrations_generator.rb b/generators/apn_migrations_generator.rb
index 63375981..a15d087a 100644
--- a/generators/apn_migrations_generator.rb
+++ b/generators/apn_migrations_generator.rb
@@ -9,11 +9,13 @@ def manifest # :nodoc:
       
       m.directory(db_migrate_path)
       
-      ['001_create_apn_devices', '002_create_apn_notifications'].each_with_index do |f, i|
+      Dir.glob(File.join(File.dirname(__FILE__), 'templates', 'apn_migrations', '*.rb')).sort.each_with_index do |f, i|
+        f = File.basename(f)
+        f.match(/\d+\_(.+)/)
         timestamp = timestamp.succ
-        if Dir.glob(File.join(db_migrate_path, "*_#{f}.rb")).empty?
-          m.file(File.join('apn_migrations', "#{f}.rb"), 
-                 File.join(db_migrate_path, "#{timestamp}_#{f}.rb"), 
+        if Dir.glob(File.join(db_migrate_path, "*_#{$1}")).empty?
+          m.file(File.join('apn_migrations', f), 
+                 File.join(db_migrate_path, "#{timestamp}_#{$1}"), 
                  {:collision => :skip})
         end
       end
diff --git a/generators/templates/apn_migrations/003_add_registered_at_to_apn_devices.rb b/generators/templates/apn_migrations/003_add_registered_at_to_apn_devices.rb
new file mode 100644
index 00000000..d7797a28
--- /dev/null
+++ b/generators/templates/apn_migrations/003_add_registered_at_to_apn_devices.rb
@@ -0,0 +1,22 @@
+class AddRegisteredAtToApnDevices < ActiveRecord::Migration # :nodoc:
+  
+  module APN
+    class Device < ActiveRecord::Base
+      set_table_name 'apn_devices'
+    end
+  end
+  
+  def self.up
+    add_column :apn_devices, :last_registered_at, :datetime
+    
+    APN::Device.all.each do |device|
+      device.last_registered_at = device.created_at
+      device.save!
+    end
+    
+  end
+
+  def self.down
+    remove_column :apn_devices, :last_registered_at
+  end
+end
diff --git a/lib/apn_on_rails/app/models/apn/base.rb b/lib/apn_on_rails/app/models/apn/base.rb
new file mode 100644
index 00000000..c54fdc51
--- /dev/null
+++ b/lib/apn_on_rails/app/models/apn/base.rb
@@ -0,0 +1,9 @@
+module APN
+  class Base < ActiveRecord::Base # :nodoc:
+    
+    def self.table_name # :nodoc:
+      self.to_s.gsub("::", "_").tableize
+    end
+    
+  end
+end
\ No newline at end of file
diff --git a/lib/apn_on_rails/app/models/apn/device.rb b/lib/apn_on_rails/app/models/apn/device.rb
index 63630fb4..8ad787e4 100644
--- a/lib/apn_on_rails/app/models/apn/device.rb
+++ b/lib/apn_on_rails/app/models/apn/device.rb
@@ -1,16 +1,24 @@
 # Represents an iPhone (or other APN enabled device).
 # An APN::Device can have many APN::Notification.
 # 
+# In order for the APN::Feedback system to work properly you *MUST*
+# touch the last_registered_at column everytime someone opens
+# your application. If you do not, then it is possible, and probably likely,
+# that their device will be removed and will no longer receive notifications.
+# 
 # Example:
 #   Device.create(:token => '5gxadhy6 6zmtxfl6 5zpbcxmw ez3w7ksf qscpr55t trknkzap 7yyt45sc g6jrw7qz')
-class APN::Device < ActiveRecord::Base
-  set_table_name 'apn_devices'
+class APN::Device < APN::Base
   
   has_many :notifications, :class_name => 'APN::Notification'
   
   validates_uniqueness_of :token
   validates_format_of :token, :with => /^[a-z0-9]{8}\s[a-z0-9]{8}\s[a-z0-9]{8}\s[a-z0-9]{8}\s[a-z0-9]{8}\s[a-z0-9]{8}\s[a-z0-9]{8}\s[a-z0-9]{8}$/
   
+  before_save :set_last_registered_at
+  
+  attr_accessor :feedback_at
+  
   # Stores the token (Apple's device ID) of the iPhone (device).
   # 
   # If the token comes in like this:
@@ -29,4 +37,9 @@ def to_hexa
     [self.token.delete(' ')].pack('H*')
   end
   
+  private
+  def set_last_registered_at
+    self.last_registered_at = Time.now if self.last_registered_at.nil?
+  end
+  
 end
\ No newline at end of file
diff --git a/lib/apn_on_rails/app/models/apn/notification.rb b/lib/apn_on_rails/app/models/apn/notification.rb
index 1966e06e..b69962db 100644
--- a/lib/apn_on_rails/app/models/apn/notification.rb
+++ b/lib/apn_on_rails/app/models/apn/notification.rb
@@ -14,12 +14,10 @@
 # 
 # As each APN::Notification is sent the sent_at column will be timestamped,
 # so as to not be sent again.
-class APN::Notification < ActiveRecord::Base
+class APN::Notification < APN::Base
   include ::ActionView::Helpers::TextHelper
   extend ::ActionView::Helpers::TextHelper
   
-  set_table_name 'apn_notifications'
-  
   belongs_to :device, :class_name => 'APN::Device'
   
   # Stores the text alert message you want to send to the device.
diff --git a/lib/apn_on_rails/app/models/apn/connection.rb b/lib/apn_on_rails/libs/connection.rb
similarity index 100%
rename from lib/apn_on_rails/app/models/apn/connection.rb
rename to lib/apn_on_rails/libs/connection.rb
diff --git a/lib/apn_on_rails/libs/feedback.rb b/lib/apn_on_rails/libs/feedback.rb
new file mode 100644
index 00000000..b800879e
--- /dev/null
+++ b/lib/apn_on_rails/libs/feedback.rb
@@ -0,0 +1,42 @@
+module APN
+  # Module for talking to the Apple Feedback Service.
+  # The service is meant to let you know when a device is no longer
+  # registered to receive notifications for your application.
+  module Feedback
+    
+    class << self
+      
+      # Returns an Array of APN::Device objects that
+      # has received feedback from Apple. Each APN::Device will
+      # have it's feedback_at accessor marked with the time
+      # that Apple believes the device de-registered itself.
+      def devices(&block)
+        devices = []
+        APN::Connection.open_for_feedback do |conn, sock|
+          while line = sock.gets   # Read lines from the socket
+            line.strip!
+            feedback = line.unpack('N1n1H140')
+            token = feedback[2].scan(/.{0,8}/).join(' ').strip
+            device = APN::Device.find(:first, :conditions => {:token => token})
+            if device
+              device.feedback_at = Time.at(feedback[0])
+              devices << device
+            end
+          end
+        end
+        devices.each(&block) if block_given?
+        return devices
+      end # devices
+      
+      def process_devices
+        APN::Feedback.devices.each do |device|
+          if device.last_registered_at < device.feedback_at
+            device.destroy
+          end
+        end
+      end # process_devices
+      
+    end # class << self
+    
+  end # Feedback
+end # APN
\ No newline at end of file
diff --git a/lib/apn_on_rails/tasks/apn.rake b/lib/apn_on_rails/tasks/apn.rake
index d50a1e33..3c02792f 100644
--- a/lib/apn_on_rails/tasks/apn.rake
+++ b/lib/apn_on_rails/tasks/apn.rake
@@ -9,4 +9,13 @@ namespace :apn do
     
   end # notifications
   
+  namespace :feedback do
+    
+    desc "Process all devices that have feedback from APN."
+    task :process => [:environment] do
+      APN::Feedback.process_devices
+    end
+    
+  end
+  
 end # apn
\ No newline at end of file
diff --git a/spec/apn_on_rails/app/models/apn/device_spec.rb b/spec/apn_on_rails/app/models/apn/device_spec.rb
index 960dc80e..f96408d5 100644
--- a/spec/apn_on_rails/app/models/apn/device_spec.rb
+++ b/spec/apn_on_rails/app/models/apn/device_spec.rb
@@ -40,4 +40,21 @@
     
   end
   
+  describe 'before_save' do
+    
+    it 'should set the last_registered_at date to Time.now if nil' do
+      time = Time.now
+      Time.stub(:now).and_return(time)
+      device = DeviceFactory.create
+      device.last_registered_at.should_not be_nil
+      device.last_registered_at.to_s.should == time.to_s
+      
+      ago = 1.week.ago
+      device = DeviceFactory.create(:last_registered_at => ago)
+      device.last_registered_at.should_not be_nil
+      device.last_registered_at.to_s.should == ago.to_s
+    end
+    
+  end
+  
 end
\ No newline at end of file
diff --git a/spec/apn_on_rails/app/models/apn/notification_spec.rb b/spec/apn_on_rails/app/models/apn/notification_spec.rb
index 16db6633..45774c18 100644
--- a/spec/apn_on_rails/app/models/apn/notification_spec.rb
+++ b/spec/apn_on_rails/app/models/apn/notification_spec.rb
@@ -70,7 +70,7 @@
       ssl_mock = mock('ssl_mock')
       ssl_mock.should_receive(:write).with('message-0')
       ssl_mock.should_receive(:write).with('message-1')
-      APN::Connection.should_receive(:open_for_delivery).and_yield(ssl_mock)
+      APN::Connection.should_receive(:open_for_delivery).and_yield(ssl_mock, nil)
       
       APN::Notification.send_notifications(notifications)
       
diff --git a/spec/apn_on_rails/app/models/apn/connection_spec.rb b/spec/apn_on_rails/libs/connection_spec.rb
similarity index 90%
rename from spec/apn_on_rails/app/models/apn/connection_spec.rb
rename to spec/apn_on_rails/libs/connection_spec.rb
index 1f78e0a8..cd52acc4 100644
--- a/spec/apn_on_rails/app/models/apn/connection_spec.rb
+++ b/spec/apn_on_rails/libs/connection_spec.rb
@@ -1,4 +1,4 @@
-require File.join(File.dirname(__FILE__), '..', '..', '..', '..', 'spec_helper.rb')
+require File.dirname(__FILE__) + '/../../spec_helper'
 
 describe APN::Connection do
 
@@ -28,7 +28,7 @@
       ssl_mock.should_receive(:close)
       OpenSSL::SSL::SSLSocket.should_receive(:new).with(tcp_mock, ctx_mock).and_return(ssl_mock)
       
-      APN::Connection.open_for_delivery do |conn|
+      APN::Connection.open_for_delivery do |conn, sock|
         conn.write('message-0')
         conn.write('message-1')
       end
@@ -37,4 +37,4 @@
     
   end
   
-end
\ No newline at end of file
+end
diff --git a/spec/apn_on_rails/libs/feedback_spec.rb b/spec/apn_on_rails/libs/feedback_spec.rb
new file mode 100644
index 00000000..b7b19a25
--- /dev/null
+++ b/spec/apn_on_rails/libs/feedback_spec.rb
@@ -0,0 +1,57 @@
+require File.dirname(__FILE__) + '/../../spec_helper'
+
+describe APN::Feedback do
+  
+  describe 'devices' do
+    
+    before(:each) do
+      @time = Time.now
+      @device = DeviceFactory.create
+      
+      @data_mock = mock('data_mock')
+      @data_mock.should_receive(:strip!)
+      @data_mock.should_receive(:unpack).with('N1n1H140').and_return([@time.to_i, 12388, @device.token.delete(' ')])
+      
+      @ssl_mock = mock('ssl_mock')
+      @sock_mock = mock('sock_mock')
+      @sock_mock.should_receive(:gets).twice.and_return(@data_mock, nil)
+      
+    end
+    
+    it 'should an Array of devices that need to be processed' do
+      APN::Connection.should_receive(:open_for_feedback).and_yield(@ssl_mock, @sock_mock)
+      
+      devices = APN::Feedback.devices
+      devices.size.should == 1
+      r_device = devices.first
+      r_device.token.should == @device.token
+      r_device.feedback_at.to_s.should == @time.to_s
+    end
+    
+    it 'should yield up each device' do
+      APN::Connection.should_receive(:open_for_feedback).and_yield(@ssl_mock, @sock_mock)
+      lambda {
+        APN::Feedback.devices do |r_device|
+          r_device.token.should == @device.token
+          r_device.feedback_at.to_s.should == @time.to_s
+          raise BlockRan.new
+        end
+      }.should raise_error(BlockRan)
+    end
+    
+  end
+  
+  describe 'process_devices' do
+    
+    it 'should destroy devices that have a last_registered_at date that is before the feedback_at date' do
+      devices = [DeviceFactory.create(:last_registered_at => 1.week.ago, :feedback_at => Time.now),
+                 DeviceFactory.create(:last_registered_at => 1.week.from_now, :feedback_at => Time.now)]
+      APN::Feedback.should_receive(:devices).and_return(devices)
+      lambda {
+        APN::Feedback.process_devices
+      }.should change(APN::Device, :count).by(-1)
+    end
+    
+  end
+  
+end
\ No newline at end of file
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 626b7608..6ff299ec 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -50,4 +50,7 @@ def write_fixture(name, value)
 
 def apn_cert
   File.read(File.join(File.dirname(__FILE__), 'rails_root', 'config', 'apple_push_notification_development.pem'))
+end
+
+class BlockRan < StandardError
 end
\ No newline at end of file